Compare commits
82 Commits
v0.3.16
...
feat/in-ap
| Author | SHA1 | Date | |
|---|---|---|---|
| 930e1a1e37 | |||
| 55301c9c63 | |||
| 5fb7fbe66c | |||
| c1560468e6 | |||
| f86698e7cd | |||
| 1fd2c05db3 | |||
| 9f179e400d | |||
| 6a37349cf7 | |||
| 52b78d6bbc | |||
| e6d765f7bb | |||
| 5d42009348 | |||
| 8e4ddf00a8 | |||
| 18bc96082f | |||
| 6a084489ea | |||
| 80a41db34f | |||
| cf94635121 | |||
| eec61c089c | |||
| a9f87be8e5 | |||
| 25f2fc4b16 | |||
| a21914312a | |||
| f6a947ec15 | |||
| 03f3c85fd8 | |||
| 8a68e0d397 | |||
| 43e6d1aed2 | |||
| a3e3fd6da6 | |||
| b8b03048b6 | |||
|
|
ba86a71e12 | ||
|
|
6a942def3f | ||
|
|
d4fdb84a17 | ||
|
|
5585fa7ef6 | ||
|
|
b301ebbad0 | ||
|
|
feaa9f1875 | ||
|
|
b25be8fd6a | ||
|
|
3e6b0e877a | ||
|
|
9f3fc05a52 | ||
|
|
4c3721b6ac | ||
|
|
06d733f88e | ||
|
|
258c6e9c17 | ||
| feffe63019 | |||
| 34ba554fba | |||
| b2cc6e96e1 | |||
| 2a68d2f9c6 | |||
| 2898b22440 | |||
|
|
f1fd80d9ac | ||
|
|
24e3e8e989 | ||
|
|
47f2ab9cd4 | ||
|
|
36729f8f61 | ||
|
|
30b090852d | ||
|
|
f0c9568521 | ||
|
|
7c37eb58cb | ||
|
|
7a5b8dabff | ||
|
|
fef563be06 | ||
|
|
e14c707dfd | ||
|
|
fa7bb27ef5 | ||
|
|
21e9d2cab4 | ||
|
|
00ffcb1cf2 | ||
|
|
daba09472f | ||
|
|
f3208a481f | ||
|
|
a46542fcdd | ||
|
|
1ae2d9ac2c | ||
|
|
e41caa9cfe | ||
|
|
916f4ae101 | ||
| 45d389c67f | |||
|
|
7f12332cf6 | ||
| fe30f03b9f | |||
|
|
fc472d5dad | ||
| a0a2f27eac | |||
|
|
bd7adec2f0 | ||
| 9b6386c4ae | |||
|
|
9da1ae7ca1 | ||
| bc8bed3339 | |||
|
|
3fee678bd2 | ||
| be720178c2 | |||
|
|
eec0294f3e | ||
| 59a1c7956c | |||
|
|
e86949a1d7 | ||
|
|
7ca62bfc46 | ||
|
|
b60e4ae670 | ||
| ace52f8fc5 | |||
| 1ae9535516 | |||
|
|
b50cfb5049 | ||
|
|
a5227b9936 |
@@ -2,10 +2,11 @@
|
||||
.gitea
|
||||
.env.local
|
||||
.env.test
|
||||
docker/
|
||||
deploy/docker/docker-compose.prod.yml
|
||||
deploy/docker/deploy.sh
|
||||
deploy/docker/.env.example
|
||||
infra/dev/
|
||||
infra/prod/docker-compose.yml
|
||||
infra/prod/deploy.sh
|
||||
infra/prod/deploy-release.sh
|
||||
infra/prod/.env.example
|
||||
frontend/node_modules
|
||||
frontend/.nuxt
|
||||
frontend/.output
|
||||
|
||||
@@ -60,7 +60,7 @@ JWT_COOKIE_TTL=86400
|
||||
# Base de donnees (Doctrine / PostgreSQL)
|
||||
# ===========================================================================
|
||||
|
||||
# Les variables POSTGRES_* sont definies dans docker/.env.docker
|
||||
# Les variables POSTGRES_* sont definies dans infra/dev/.env.docker
|
||||
# et injectees automatiquement par Docker Compose.
|
||||
# DATABASE_URL est construite a partir de ces variables.
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
@@ -74,10 +74,10 @@ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_P
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
|
||||
# ===========================================================================
|
||||
# Docker (docker/.env.docker)
|
||||
# Docker (infra/dev/.env.docker)
|
||||
#
|
||||
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
|
||||
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
|
||||
# Ces variables sont lues par Docker Compose. Voir infra/dev/.env.docker
|
||||
# pour les valeurs par defaut. Creez infra/dev/.env.docker.local pour
|
||||
# surcharger localement.
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build \
|
||||
-f deploy/docker/Dockerfile.prod \
|
||||
-f infra/prod/Dockerfile \
|
||||
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
||||
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
||||
.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Build Release Artefact
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.4"
|
||||
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install backend deps (prod)
|
||||
env:
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: "0"
|
||||
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||
|
||||
- name: Build frontend (static)
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
||||
test -f .output/public/index.html
|
||||
|
||||
- name: Build artefact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
||||
.env \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
public \
|
||||
src \
|
||||
vendor \
|
||||
composer.json \
|
||||
composer.lock \
|
||||
symfony.lock \
|
||||
frontend/.output
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/lesstime-${{ github.ref_name }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -28,5 +28,11 @@
|
||||
###< ide ###
|
||||
|
||||
###> docker local ###
|
||||
docker/.env.docker.local
|
||||
infra/dev/.env.docker.local
|
||||
###< docker local ###
|
||||
|
||||
###> local db dumps ###
|
||||
*.sql.gz
|
||||
*.sql.gz:Zone.Identifier
|
||||
REVIEW.md
|
||||
###< local db dumps ###
|
||||
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -103,6 +103,10 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
||||
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
||||
|
||||
### Composants UI
|
||||
|
||||
La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. La documentation complète des props, events et exemples d'utilisation se trouve dans `frontend/node_modules/@malio/layer-ui/COMPONENTS.md`. Toujours s'y référer avant d'utiliser un composant Malio.
|
||||
|
||||
### MCP Server
|
||||
|
||||
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
||||
@@ -125,7 +129,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Container PHP : `php-lesstime-fpm`
|
||||
- Container Nginx : `nginx-lesstime`
|
||||
- Container DB : PostgreSQL sur port **5435** (interne et externe)
|
||||
- Config Docker : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
||||
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||
- Après modif nginx : `docker restart nginx-lesstime`
|
||||
|
||||
## Fixtures
|
||||
@@ -136,3 +140,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
||||
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
||||
|
||||
## Delegation Codex
|
||||
|
||||
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||
|
||||
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||
|
||||
C'est le meilleur ratio qualite/credits.
|
||||
|
||||
@@ -156,7 +156,7 @@ docker/ # Dockerfiles et config Nginx
|
||||
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
||||
| PostgreSQL | 5435 | Base de données |
|
||||
|
||||
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
||||
Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -21,3 +21,6 @@ mcp:
|
||||
store: file
|
||||
directory: '%kernel.project_dir%/var/mcp-sessions'
|
||||
ttl: 3600
|
||||
discovery:
|
||||
scan_dirs: ['src']
|
||||
exclude_dirs: ['DataFixtures']
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.3.16'
|
||||
app.version: '0.4.0'
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export LESSTIME_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying lesstime:${TAG}..."
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
@@ -1,14 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 55m;
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /api/login_check {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PATH_INFO /login_check;
|
||||
fastcgi_param REQUEST_URI /login_check;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ^~ /_mcp {
|
||||
root /var/www/lesstime/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ ^/index\.php(/|$) {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -109,23 +109,33 @@ export LESSTIME_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying lesstime:${TAG}..."
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
sudo docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
docker compose up -d
|
||||
sudo docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Extracting maintenance page..."
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Disabling maintenance mode..."
|
||||
rm -f maintenance.on
|
||||
|
||||
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
```
|
||||
|
||||
@@ -192,16 +202,33 @@ Creer `/etc/nginx/sites-available/lesstime.conf` :
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
client_max_body_size 55m;
|
||||
root /var/www/lesstime/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/lesstime/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -250,6 +277,8 @@ rm /tmp/lesstime.sql
|
||||
├── config/jwt/
|
||||
│ ├── private.pem
|
||||
│ └── public.pem
|
||||
├── public/
|
||||
│ └── maintenance.html # extrait automatiquement par deploy.sh
|
||||
└── uploads/
|
||||
```
|
||||
|
||||
|
||||
153
doc/setup-maintenance-mode.md
Normal file
153
doc/setup-maintenance-mode.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Configuration du mode maintenance (nginx hote)
|
||||
|
||||
Guide pour activer le support du mode maintenance pilote par Central.
|
||||
Ces etapes sont a faire **une seule fois** par application sur le serveur de production.
|
||||
|
||||
Le principe : le nginx de l'hote (reverse proxy) verifie si un fichier `maintenance.on` existe dans le dossier de deploy. Si oui, il sert une page `maintenance.html` au lieu de proxifier vers le container Docker.
|
||||
|
||||
Central pilote la creation/suppression de ce fichier via ses volumes Docker.
|
||||
|
||||
## Ce qui a ete fait pour Lesstime
|
||||
|
||||
### 1. Deployer pour extraire la page maintenance
|
||||
|
||||
```bash
|
||||
cd /var/www/lesstime
|
||||
sudo ./deploy.sh
|
||||
```
|
||||
|
||||
Le `deploy.sh` extrait automatiquement `maintenance.html` du container vers `public/` :
|
||||
```
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
```
|
||||
|
||||
### 2. Mettre a jour la conf nginx de l'hote
|
||||
|
||||
Remplacer le contenu de `/etc/nginx/sites-available/lesstime.conf` :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/lesstime/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Recharger nginx
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 4. Verifier
|
||||
|
||||
- Depuis Central, activer la maintenance sur Lesstime
|
||||
- Ouvrir `http://project.malio-dev.fr` → doit afficher la page "Maintenance en cours"
|
||||
- Desactiver la maintenance depuis Central → le site revient
|
||||
|
||||
---
|
||||
|
||||
## A faire pour Inventory
|
||||
|
||||
Meme procedure :
|
||||
|
||||
### 1. Deployer pour extraire la page maintenance
|
||||
|
||||
```bash
|
||||
cd /var/www/inventory
|
||||
sudo ./deploy.sh
|
||||
```
|
||||
|
||||
> Si le `deploy.sh` ne contient pas encore l'extraction, mettre a jour le fichier depuis le repo (`infra/prod/deploy.sh`) ou executer manuellement :
|
||||
> ```bash
|
||||
> mkdir -p public
|
||||
> sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
> ```
|
||||
|
||||
### 2. Mettre a jour la conf nginx de l'hote
|
||||
|
||||
Remplacer le contenu de `/etc/nginx/sites-available/inventory.conf` :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name inventory.malio-dev.fr;
|
||||
|
||||
root /var/www/inventory/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/inventory/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Recharger nginx
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnement
|
||||
|
||||
```
|
||||
Central (container)
|
||||
└── touch /var/www/maintenance/lesstime/maintenance.on
|
||||
│ (volume Docker : /var/www/lesstime → /var/www/maintenance/lesstime)
|
||||
▼
|
||||
/var/www/lesstime/maintenance.on (hote)
|
||||
│
|
||||
▼
|
||||
nginx hote : if (-f /var/www/lesstime/maintenance.on) → 503
|
||||
│
|
||||
▼
|
||||
maintenance.html servie depuis /var/www/lesstime/public/
|
||||
```
|
||||
@@ -2,7 +2,7 @@ services:
|
||||
php:
|
||||
container_name: php-${DOCKER_APP_NAME}-fpm
|
||||
build:
|
||||
context: ./docker/php
|
||||
context: ./infra/dev
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
||||
@@ -21,8 +21,8 @@ services:
|
||||
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
||||
- ~/.config:/var/www/.config # Pour la config de yarn
|
||||
- ~/.composer:/var/www/.composer # Pour la config de composer
|
||||
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./infra/dev/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./LOG:/var/www/html/LOG
|
||||
- uploads_data:/var/www/html/var/uploads
|
||||
extra_hosts:
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
- "8082:80"
|
||||
volumes:
|
||||
- ./:/var/www/html:ro
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
|
||||
87
docs/claude-time-tracking-rule.md
Normal file
87
docs/claude-time-tracking-rule.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Règle Claude : Time Tracking automatique via Lesstime
|
||||
|
||||
> Ajouter ce contenu dans le CLAUDE.md de chaque projet ou dans `~/.claude/CLAUDE.md` pour l'appliquer globalement.
|
||||
|
||||
---
|
||||
|
||||
## Time Tracking obligatoire
|
||||
|
||||
Claude DOIT créer une time entry dans Lesstime au démarrage de chaque tâche de développement, ou sur demande explicite de l'utilisateur ("lance le chrono", "start timer", "track le temps").
|
||||
|
||||
### Déclencheurs
|
||||
|
||||
1. **Début d'une tâche de dev** : feature, bugfix, refactoring, infra, review
|
||||
2. **Demande explicite** : "lance le chrono", "start timer", "track le temps"
|
||||
3. **Depuis un ticket Lesstime** : lier directement au taskId du ticket référencé
|
||||
|
||||
### Méthode
|
||||
|
||||
Créer la time entry via **curl** sur l'API REST Lesstime :
|
||||
|
||||
1. **Login** : `POST http://project.malio-dev.fr/api/login_check`
|
||||
- Body : `{"username":"admin","password":"admin"}`
|
||||
- Réponse : 204 avec cookie `Set-Cookie: BEARER=<jwt>`
|
||||
|
||||
2. **Créer le timer** : `POST http://project.malio-dev.fr/api/time_entries`
|
||||
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/ld+json`, `Accept: application/ld+json`
|
||||
- Body :
|
||||
```json
|
||||
{
|
||||
"user": "/api/users/5",
|
||||
"startedAt": "<ISO8601 avec timezone>",
|
||||
"title": "<description courte de la tâche>",
|
||||
"project": "/api/projects/<projectId>",
|
||||
"tags": ["/api/task_tags/<tagId>"],
|
||||
"task": "/api/tasks/<taskId>"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Stopper le timer** : `PATCH http://project.malio-dev.fr/api/time_entries/<id>`
|
||||
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/merge-patch+json`, `Accept: application/ld+json`
|
||||
- Body : `{"stoppedAt": "<ISO8601>"}`
|
||||
|
||||
### Paramètres obligatoires
|
||||
|
||||
- **user** : TOUJOURS `/api/users/5` (Matthieu)
|
||||
- **startedAt** : ISO 8601 avec timezone (ex: `2026-04-01T14:30:00+02:00`)
|
||||
- **title** : description courte de la tâche en cours
|
||||
- **project** : selon le projet (voir mapping ci-dessous)
|
||||
|
||||
### Tags (choisir selon le type de travail)
|
||||
|
||||
| Tag | ID | IRI |
|
||||
|-----|----|-----|
|
||||
| Backend | 3 | `/api/task_tags/3` |
|
||||
| Frontend | 2 | `/api/task_tags/2` |
|
||||
| IA | 7 | `/api/task_tags/7` |
|
||||
| Infra | 5 | `/api/task_tags/5` |
|
||||
| UI/UX | 4 | `/api/task_tags/4` |
|
||||
| Maintenance | 6 | `/api/task_tags/6` |
|
||||
| RDV | 1 | `/api/task_tags/1` |
|
||||
| Réunion | 8 | `/api/task_tags/8` |
|
||||
| Formation | 10 | `/api/task_tags/10` |
|
||||
| Gestion projet | 9 | `/api/task_tags/9` |
|
||||
|
||||
### Mapping projets
|
||||
|
||||
| Projet | ID | IRI |
|
||||
|--------|----|-----|
|
||||
| Lesstime | 5 | `/api/projects/5` |
|
||||
| Inventory | 7 | `/api/projects/7` |
|
||||
| SIRH | 12 | `/api/projects/12` |
|
||||
| Infrastructure | 13 | `/api/projects/13` |
|
||||
| Malio UI | 11 | `/api/projects/11` |
|
||||
| ERP Liot | 6 | `/api/projects/6` |
|
||||
| Ferme | 8 | `/api/projects/8` |
|
||||
| ADMIN | 16 | `/api/projects/16` |
|
||||
| Maintenance-LIOT | 17 | `/api/projects/17` |
|
||||
| Qualiopi | 14 | `/api/projects/14` |
|
||||
| Vaultwarden | 18 | `/api/projects/18` |
|
||||
|
||||
### Règles
|
||||
|
||||
- **Un seul timer actif à la fois** (contrainte DB) — stopper l'actif avant d'en créer un nouveau
|
||||
- **Toujours stopper le timer** en fin de tâche ou sur demande
|
||||
- **Informer l'utilisateur** quand un timer est lancé/stoppé (numéro, titre, projet, tags)
|
||||
- **Lier au ticket Lesstime** si un ticket est référencé (champ `task`)
|
||||
- **Choisir les tags intelligemment** selon le type de travail effectué
|
||||
@@ -61,7 +61,7 @@ ENCRYPTION_KEY=<random-hex-32>
|
||||
## 4. Installer le script de deploy
|
||||
|
||||
```bash
|
||||
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
|
||||
sudo cp infra/prod/deploy-release.sh /usr/local/bin/deploy-lesstime
|
||||
sudo chmod +x /usr/local/bin/deploy-lesstime
|
||||
```
|
||||
|
||||
@@ -89,7 +89,7 @@ sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --e
|
||||
## 7. Configurer Nginx
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
|
||||
sudo cp infra/prod/nginx-baremetal.conf /etc/nginx/sites-available/lesstime
|
||||
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
3036
docs/superpowers/plans/2026-05-19-project-workflows.md
Normal file
3036
docs/superpowers/plans/2026-05-19-project-workflows.md
Normal file
File diff suppressed because it is too large
Load Diff
224
docs/superpowers/specs/2026-05-19-project-workflows-design.md
Normal file
224
docs/superpowers/specs/2026-05-19-project-workflows-design.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Workflows de statuts par projet (Kanban custom)
|
||||
|
||||
**Date** : 2026-05-19
|
||||
**Branche** : `feat/project-workflows`
|
||||
**Statut** : design validé (2026-05-19, par Matthieu), en attente de plan d'implémentation
|
||||
|
||||
## Reprise sur un autre poste
|
||||
|
||||
> **Pour le prochain Claude qui ouvre cette branche :**
|
||||
>
|
||||
> 1. Branche `feat/project-workflows` checkout-ée, basée sur `develop` (commit `5585fa7` à l'origine).
|
||||
> 2. **Ce qui est fait** : design validé avec Matthieu et committé (ce fichier).
|
||||
> 3. **Aucun code applicatif n'a encore été écrit.**
|
||||
> 4. **Prochaine étape** : invoquer la skill `superpowers:writing-plans` pour transformer ce design en plan d'implémentation détaillé (découpage en tickets ordonnés, dépendances, estimations).
|
||||
> 5. **Validations Matthieu (2026-05-19)** :
|
||||
> - Hors scope (§8) → MCP `switch-project-workflow` **rapatrié dans la V1** (cf. §6).
|
||||
> - Fallback `in_progress` pour statuts non-mappables → **abandonné**. Seuls les 5 statuts standards existent ; la migration M2 échoue explicitement si elle rencontre autre chose.
|
||||
> - Suppression d'`AdminStatusTab` → **OK**.
|
||||
> - Ordre des étapes de livraison (§10) → **OK**.
|
||||
> 6. **Time tracking** : créer un nouveau timer Lesstime au reprise (projet=5 Lesstime, tags=[3 Backend, 9 Gestion projet]).
|
||||
> 7. **Fichiers déjà modifiés sur develop (orphelins, pas liés à cette feature)** à ne PAS toucher : `.mcp.json`, `config/reference.php`, `frontend/package-lock.json`, `frontend/pages/profile.vue`.
|
||||
|
||||
## 1. Contexte et besoin
|
||||
|
||||
Aujourd'hui les `TaskStatus` sont globaux : tous les projets partagent le même jeu de 5 statuts (À faire / En cours / Bloqué / En attente de validation / Terminé). Pour les gros projets de dev, on veut pouvoir définir un kanban plus riche (ex : Backlog / To Do / In Dev / Code Review / QA / Blocked / Ready to deploy / Done) sans imposer ce détail aux projets simples.
|
||||
|
||||
**Objectif** : permettre à chaque projet d'avoir son propre jeu de colonnes kanban, via des **templates de workflows réutilisables** définis en admin et assignés à un projet, sans casser les projets existants ni les vues transverses (`my-tasks`, time-tracking, dashboards, MCP).
|
||||
|
||||
## 2. Modèle de données
|
||||
|
||||
### Nouvelle entité : `Workflow`
|
||||
|
||||
```
|
||||
Workflow
|
||||
- id int, PK
|
||||
- name string(255), unique
|
||||
- isDefault bool (un seul = true ; assigné aux projets sans workflow explicite ; unicité garantie par un listener Doctrine PrePersist/PreUpdate)
|
||||
- position int (pour l'ordre dans l'admin)
|
||||
- statuses OneToMany → TaskStatus (inverse côté Workflow)
|
||||
```
|
||||
|
||||
### Modifications : `TaskStatus`
|
||||
|
||||
```
|
||||
TaskStatus
|
||||
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=CASCADE
|
||||
+ category string, enum PHP : 'todo' | 'in_progress' | 'blocked' | 'review' | 'done', NOT NULL
|
||||
~ position devient relatif au workflow (idéalement contrainte unique (workflow_id, position))
|
||||
- isFinal conservé tel quel — distinct de category='done' (permet un statut "Annulé" final ≠ done)
|
||||
```
|
||||
|
||||
### Modifications : `Project`
|
||||
|
||||
```
|
||||
Project
|
||||
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=RESTRICT
|
||||
```
|
||||
|
||||
### Choix de design
|
||||
|
||||
- **Pas de partage de statuts entre workflows** : chaque workflow a SES PROPRES rows `TaskStatus`. "À faire" du workflow Standard ≠ "À faire" de Dev Kanban (IDs et couleurs distincts). Évite les bugs de couplage, simplifie le mapping lors du switch.
|
||||
- **`category` obligatoire** : pivot pour les vues transverses + mapping auto lors du switch. 5 valeurs : `todo`, `in_progress`, `blocked`, `review`, `done`.
|
||||
- **Plusieurs statuts peuvent partager la même catégorie** dans un workflow (ex : 3 statuts en `review` dans Dev Kanban). La catégorie n'est pas une contrainte, juste un bucket de regroupement.
|
||||
- **`onDelete=RESTRICT` sur `Project.workflow_id`** : un workflow ne peut pas être supprimé s'il a au moins un projet attaché. Protection à 3 niveaux (DB / API / UI).
|
||||
- **Suppression de TaskStatus** : reste protégée comme aujourd'hui via le flow `ConfirmDeleteStatusModal` (réassignation des tâches à un autre statut ou null).
|
||||
|
||||
## 3. Migrations BDD
|
||||
|
||||
Trois migrations Doctrine successives :
|
||||
|
||||
**M1 — `create_workflow_table`**
|
||||
- Crée la table `workflow` (id, name, is_default, position)
|
||||
- Insère le workflow par défaut `Standard` (is_default=true, position=0)
|
||||
|
||||
**M2 — `add_workflow_to_task_status`**
|
||||
- Ajoute `task_status.workflow_id` nullable + `task_status.category` nullable
|
||||
- `UPDATE task_status SET workflow_id = <id Standard>` pour toutes les lignes existantes
|
||||
- Backfill catégories (uniquement les 5 statuts standards existants — confirmé avec Matthieu 2026-05-19) :
|
||||
- "À faire" → `todo`
|
||||
- "En cours" → `in_progress`
|
||||
- "Bloqué" → `blocked`
|
||||
- "En attente de validation" → `review`
|
||||
- "Terminé" → `done`
|
||||
- La migration **échoue** (exception) si elle rencontre un label non listé → garde-fou explicite contre toute prod qui aurait dérivé.
|
||||
- Passe les 2 colonnes en `NOT NULL`
|
||||
|
||||
**M3 — `add_workflow_to_project`**
|
||||
- Ajoute `project.workflow_id` nullable
|
||||
- `UPDATE project SET workflow_id = <id Standard>` pour tous les projets existants
|
||||
- Passe en `NOT NULL` avec FK `ON DELETE RESTRICT`
|
||||
|
||||
## 4. Backend (Symfony / API Platform)
|
||||
|
||||
### Entités
|
||||
|
||||
- `App\Entity\Workflow` — nouvelle entité, ApiResource avec `ROLE_ADMIN` pour Post/Patch/Delete
|
||||
- `App\Enum\StatusCategory` — enum PHP avec les 5 valeurs canoniques
|
||||
- `App\Entity\TaskStatus` — ajout des propriétés `workflow` (ManyToOne) et `category` (StatusCategory)
|
||||
- `App\Entity\Project` — ajout de la propriété `workflow` (ManyToOne, requise)
|
||||
|
||||
### Sérialisation
|
||||
|
||||
- Groupe `workflow:read` pour l'API admin
|
||||
- `task_status:read` ajoute `workflow` et `category`
|
||||
- `project:read` embarque le workflow (ou son IRI) — décision à arbitrer dans le plan d'impl (vraisemblablement embarqué pour limiter les round-trips)
|
||||
|
||||
### Endpoint dédié au switch
|
||||
|
||||
```
|
||||
POST /api/projects/{id}/switch-workflow
|
||||
Body: {
|
||||
workflowId: int,
|
||||
mapping: { "<sourceStatusId>": <targetStatusId> | null, ... }
|
||||
}
|
||||
Security: ROLE_ADMIN
|
||||
```
|
||||
|
||||
**Processor** : `App\State\SwitchProjectWorkflowProcessor`
|
||||
1. Valide qu'il y a une entrée de mapping pour chaque `statusId` actuellement référencé par les tâches du projet (sinon 422 avec liste des sources manquantes)
|
||||
2. Valide que chaque target appartient bien au workflow cible (ou est `null`)
|
||||
3. Transaction unique :
|
||||
- Pour chaque entrée du mapping : `UPDATE task SET status_id = <target> WHERE project_id = X AND status_id = <source>`
|
||||
- `UPDATE project SET workflow_id = <new>`
|
||||
4. Retourne `{ project, migratedTaskCount }`
|
||||
|
||||
### Validation cross-entity
|
||||
|
||||
- Sur `Task` (Post/Patch) : si `status` fourni, valider que `status.workflow === task.project.workflow`. Sinon 422 `"Status does not belong to this project's workflow"`.
|
||||
|
||||
### Suppression d'un Workflow
|
||||
|
||||
- `WorkflowProcessor` (custom Delete) : compte les projets liés ; si > 0, renvoie 409 Conflict avec `{ linkedProjectIds: [...], message: "Workflow used by N project(s)" }`
|
||||
|
||||
## 5. Frontend (Nuxt / Vue)
|
||||
|
||||
### Nouveaux fichiers
|
||||
|
||||
- `frontend/services/workflows.ts` — service API CRUD
|
||||
- `frontend/services/dto/workflow.ts` — type TS
|
||||
- `frontend/components/admin/AdminWorkflowTab.vue` — nouvel onglet dans `/admin`
|
||||
- `frontend/components/admin/WorkflowDrawer.vue` — drawer création/édition workflow (nom + liste éditable des statuts avec leur catégorie)
|
||||
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` — modal de migration
|
||||
|
||||
### Modifications
|
||||
|
||||
- `frontend/components/admin/AdminStatusTab.vue` :
|
||||
- **Supprimé.** Toute la gestion des statuts passe par l'onglet Workflows (un workflow = nom + sa liste de statuts éditable inline). Évite la confusion "où je crée un statut ?".
|
||||
- `frontend/components/project/ProjectDrawer.vue` :
|
||||
- Affiche le workflow actuel
|
||||
- Bouton "Changer de workflow" qui ouvre `ProjectWorkflowSwitchModal`
|
||||
- `frontend/pages/projects/[id]/index.vue` :
|
||||
- Charge `project.workflow.statuses` au lieu de `statusService.getAll()`
|
||||
- Le kanban a les colonnes du workflow du projet
|
||||
- `frontend/pages/projects/[id]/archives.vue` :
|
||||
- Filtre statut limité au workflow du projet
|
||||
- `frontend/pages/my-tasks.vue` :
|
||||
- **Kanban groupé par catégorie** : 5 colonnes (Todo / In Progress / Blocked / Review / Done)
|
||||
- Chaque card affiche le statut spécifique en badge
|
||||
- Vue liste : pas de changement
|
||||
- `frontend/components/task/TaskModal.vue` :
|
||||
- Reçoit `:statuses` filtrés par workflow du projet via les pages parentes (déjà la pattern actuelle)
|
||||
- `frontend/components/task/TaskBulkActions.vue` :
|
||||
- Dropdown statut filtré au workflow du projet de la tâche sélectionnée
|
||||
- Si tâches multi-projets : bouton "Changer le statut" désactivé avec tooltip explicatif
|
||||
|
||||
### `ProjectWorkflowSwitchModal.vue` — détails UX
|
||||
|
||||
- Étape 1 : `MalioSelect` des workflows disponibles (sauf le workflow actuel)
|
||||
- Étape 2 (après sélection) : tableau de mapping
|
||||
- Une ligne par statut source effectivement utilisé par les tâches du projet (count > 0) + une ligne "Backlog" si des tâches `status=null`
|
||||
- Colonnes : Source (label + badge catégorie) → Cible (`MalioSelect` des statuts du workflow cible, pré-rempli intelligemment) → Nb de tâches concernées
|
||||
- Pré-remplissage : pour chaque source, on cherche dans le workflow cible le statut de **même catégorie** avec la plus petite `position`. Si aucune correspondance par catégorie, l'utilisateur doit choisir manuellement.
|
||||
- Option "Mapper vers le backlog" sur chaque ligne (= cible `null`)
|
||||
- Footer :
|
||||
- Bouton "Confirmer la migration" désactivé tant qu'au moins un mapping est manquant
|
||||
- Toast au succès : "N tâches migrées, projet sur workflow '<nom>'"
|
||||
|
||||
## 6. MCP
|
||||
|
||||
| Tool | Changement |
|
||||
|---|---|
|
||||
| `list-statuses` | Ajout d'un param optionnel `projectId?: int`. Si fourni → renvoie les statuts du workflow du projet. Sinon → renvoie tous les statuts avec `workflowId` et `category` ajoutés. Description mise à jour pour mentionner les workflows. |
|
||||
| `list-workflows` (nouveau) | Liste tous les workflows avec leurs statuts groupés (`{ id, name, isDefault, statuses: [...] }`). |
|
||||
| `create-task` / `update-task` | La validation backend (côté entité Task) rejette automatiquement un `status` n'appartenant pas au workflow du projet. Documenter dans la description du tool. |
|
||||
| `switch-project-workflow` (nouveau, ROLE_ADMIN) | Wrappe l'endpoint `POST /api/projects/{id}/switch-workflow`. Params : `projectId`, `workflowId`, `mapping: { [sourceStatusId]: targetStatusId \| null }`. Renvoie `{ migratedTaskCount }`. Mêmes validations que l'endpoint HTTP. |
|
||||
|
||||
## 7. Permissions
|
||||
|
||||
| Action | Rôle requis |
|
||||
|---|---|
|
||||
| Lire les workflows et leurs statuts | `ROLE_USER` |
|
||||
| Créer / éditer / supprimer un workflow | `ROLE_ADMIN` |
|
||||
| Créer / éditer / supprimer un statut | `ROLE_ADMIN` |
|
||||
| Changer le workflow d'un projet (switch) | `ROLE_ADMIN` |
|
||||
|
||||
## 8. Hors scope (YAGNI explicites)
|
||||
|
||||
- **Workflows en read-only intégrés** (ex : "Scrum officiel" non éditable) — pas besoin pour l'instant
|
||||
- **Transitions autorisées** entre statuts (ex : impossible de passer de "Backlog" directement à "Done") — pas demandé, ajouterait beaucoup de complexité
|
||||
- **Versioning des workflows** (historique des modifs) — pas demandé
|
||||
- **Workflow par groupe de tâches** (TaskGroup avec son propre workflow dans un projet) — pas demandé
|
||||
|
||||
## 9. Risques et limites
|
||||
|
||||
- **Migration M2 (backfill catégories)** : la migration échoue si elle rencontre un label de statut autre que les 5 standards. Si la prod a dérivé entre temps, ajouter le mapping manuellement à la migration avant déploiement.
|
||||
- **`my-tasks` kanban groupé** : avec des projets multi-workflows, l'utilisateur voit une card "In Dev" et une card "En cours" dans la même colonne `in_progress`. Le badge statut sur la card doit rester lisible (taille suffisante, couleur du statut).
|
||||
- **Filtre statut dans `my-tasks` (vue liste)** : aujourd'hui pas de filtre statut côté `my-tasks` (cf. code), donc rien à adapter. Si on en ajoute un plus tard, il faudra qu'il propose les catégories plutôt que les statuts spécifiques.
|
||||
- **Sélection multi-projets dans `TaskBulkActions`** : le bouton "Changer de statut" se désactive ; à valider que le reste du bulk reste utilisable (assignee, priorité, effort, group — eux restent globaux ou per-project comme aujourd'hui).
|
||||
|
||||
## 10. Étapes de livraison suggérées
|
||||
|
||||
1. Migrations BDD + entité `Workflow` + enum `StatusCategory` + adaptations entités `TaskStatus` et `Project`
|
||||
2. Validation cross-entity sur `Task` + sérialisation des nouvelles propriétés
|
||||
3. Endpoint `POST /api/projects/{id}/switch-workflow` + processor
|
||||
4. Service frontend `workflows` + types DTO
|
||||
5. UI admin : `AdminWorkflowTab` + `WorkflowDrawer`
|
||||
6. Adaptation `projects/[id]/index.vue` (kanban filtré par workflow)
|
||||
7. Adaptation `my-tasks.vue` (kanban groupé par catégorie)
|
||||
8. `ProjectWorkflowSwitchModal` + intégration dans `ProjectDrawer`
|
||||
9. Adaptation `TaskBulkActions` et autres écrans transverses
|
||||
10. MCP : modification `list-statuses` + nouveaux `list-workflows` et `switch-project-workflow` + mise à jour des descriptions
|
||||
11. Tests : PHPUnit pour le processor + validation cross-entity ; tests fonctionnels du switch (HTTP + MCP)
|
||||
|
||||
Le découpage exact (tickets, ordre, dépendances) sera fait dans le plan d'implémentation.
|
||||
@@ -10,21 +10,17 @@
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.tokenId"
|
||||
:label="$t('bookstack.settings.tokenId')"
|
||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.tokenSecret"
|
||||
:label="$t('bookstack.settings.tokenSecret')"
|
||||
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('bookstack.settings.tokenConfigured') }}
|
||||
|
||||
@@ -11,12 +11,10 @@
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.token"
|
||||
:label="$t('gitea.settings.token')"
|
||||
:placeholder="$t('gitea.settings.tokenPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('gitea.settings.tokenConfigured') }}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un statut"
|
||||
@click="openCreate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun statut trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="requestDelete"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<TaskStatusDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteStatusModal
|
||||
v-model="confirmModalOpen"
|
||||
:status-label="statusToDelete?.label ?? ''"
|
||||
:task-count="affectedTaskCount"
|
||||
:available-statuses="reassignTargets"
|
||||
@confirm="onConfirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'label', label: 'Libellé', primary: true },
|
||||
{ key: 'color', label: 'Couleur' },
|
||||
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
|
||||
]
|
||||
|
||||
const statusService = useTaskStatusService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const items = ref<TaskStatus[]>([])
|
||||
const tasks = ref<Task[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskStatus | null>(null)
|
||||
const confirmModalOpen = ref(false)
|
||||
const statusToDelete = ref<TaskStatus | null>(null)
|
||||
|
||||
const affectedTaskCount = computed(() => {
|
||||
if (!statusToDelete.value) return 0
|
||||
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
|
||||
})
|
||||
|
||||
const reassignTargets = computed(() => {
|
||||
if (!statusToDelete.value) return items.value
|
||||
return items.value.filter(s => s.id !== statusToDelete.value!.id)
|
||||
})
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [statuses, allTasks] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
taskService.getAll(),
|
||||
])
|
||||
items.value = statuses
|
||||
tasks.value = allTasks
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskStatus) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function requestDelete(item: TaskStatus) {
|
||||
statusToDelete.value = item
|
||||
const count = tasks.value.filter(t => t.status?.id === item.id).length
|
||||
if (count === 0) {
|
||||
await statusService.remove(item.id)
|
||||
await loadItems()
|
||||
} else {
|
||||
confirmModalOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirmDelete(targetStatusId: number | null) {
|
||||
if (!statusToDelete.value) return
|
||||
|
||||
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
|
||||
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
|
||||
|
||||
await Promise.all(
|
||||
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
|
||||
)
|
||||
|
||||
await statusService.remove(statusToDelete.value.id)
|
||||
confirmModalOpen.value = false
|
||||
statusToDelete.value = null
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
100
frontend/components/admin/AdminWorkflowTab.vue
Normal file
100
frontend/components/admin/AdminWorkflowTab.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('workflows.title') }}</h2>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('workflows.addWorkflow')"
|
||||
@click="openCreate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun workflow trouvé."
|
||||
deletable
|
||||
@row-click="openEdit"
|
||||
@delete="requestDelete"
|
||||
>
|
||||
<template #cell-isDefault="{ item }">
|
||||
<span
|
||||
v-if="item.isDefault"
|
||||
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700"
|
||||
>
|
||||
{{ $t('workflows.isDefault') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-statusCount="{ item }">
|
||||
{{ item.statuses.length }}
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<WorkflowDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow } from '~/services/dto/workflow'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'name', label: t('workflows.name'), primary: true },
|
||||
{ key: 'isDefault', label: t('workflows.isDefault') },
|
||||
{ key: 'statusCount', label: t('workflows.statuses') },
|
||||
{ key: 'position', label: 'Position' },
|
||||
]
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
|
||||
const items = ref<Workflow[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<Workflow | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await workflowService.getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: Workflow) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function requestDelete(item: Workflow) {
|
||||
try {
|
||||
await workflowService.remove(item.id)
|
||||
await loadItems()
|
||||
} catch {
|
||||
// Toast d'erreur déjà émis par useApi
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
@@ -22,11 +22,10 @@
|
||||
input-class="w-full"
|
||||
/>
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
:label="$t('zimbra.settings.password')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('zimbra.settings.passwordConfigured') }}
|
||||
|
||||
261
frontend/components/admin/WorkflowDrawer.vue
Normal file
261
frontend/components/admin/WorkflowDrawer.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow')">
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="$t('workflows.name')"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? $t('workflows.name') + ' requis' : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="isDefault"
|
||||
v-model="form.isDefault"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<label for="isDefault" class="text-sm font-medium text-neutral-700">
|
||||
{{ $t('workflows.isDefault') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-bold text-neutral-900">{{ $t('workflows.statuses') }}</h3>
|
||||
<MalioButton
|
||||
type="button"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 py-1 text-xs"
|
||||
:label="$t('workflows.addStatus')"
|
||||
@click="addStatus"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
<div
|
||||
v-for="(s, idx) in form.statuses"
|
||||
:key="idx"
|
||||
class="rounded border border-neutral-200 p-3"
|
||||
>
|
||||
<div class="flex items-end gap-2">
|
||||
<MalioInputText
|
||||
v-model="s.label"
|
||||
label="Libellé"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<select
|
||||
v-model="s.category"
|
||||
class="h-10 rounded border border-neutral-300 px-2 text-sm"
|
||||
aria-label="Catégorie"
|
||||
>
|
||||
<option v-for="c in categoryOptions" :key="c.value" :value="c.value">
|
||||
{{ c.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-2 text-red-600 hover:text-red-800"
|
||||
aria-label="Supprimer"
|
||||
@click="removeStatus(idx)"
|
||||
>
|
||||
<Icon name="mdi:delete" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<ColorPicker v-model="s.color" />
|
||||
<label class="ml-auto flex items-center gap-1 text-xs text-neutral-700">
|
||||
<input v-model="s.isFinal" type="checkbox" class="h-3 w-3" />
|
||||
{{ $t('archive.statusFinal') }}
|
||||
</label>
|
||||
<label class="flex flex-col text-xs text-neutral-700">
|
||||
Position
|
||||
<input
|
||||
v-model.number="s.position"
|
||||
type="number"
|
||||
class="mt-1 h-9 w-16 rounded border border-neutral-300 px-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
||||
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: Workflow | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: v => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
type StatusForm = {
|
||||
id?: number
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
isFinal: boolean
|
||||
category: StatusCategory
|
||||
}
|
||||
|
||||
const form = reactive<{
|
||||
name: string
|
||||
isDefault: boolean
|
||||
statuses: StatusForm[]
|
||||
}>({
|
||||
name: '',
|
||||
isDefault: false,
|
||||
statuses: [],
|
||||
})
|
||||
|
||||
const touched = reactive({ name: false })
|
||||
|
||||
const categoryOptions: { value: StatusCategory, label: string }[] = [
|
||||
{ value: 'todo', label: t('workflows.categories.todo') },
|
||||
{ value: 'in_progress', label: t('workflows.categories.in_progress') },
|
||||
{ value: 'blocked', label: t('workflows.categories.blocked') },
|
||||
{ value: 'review', label: t('workflows.categories.review') },
|
||||
{ value: 'done', label: t('workflows.categories.done') },
|
||||
]
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (!open) return
|
||||
if (props.item) {
|
||||
form.name = props.item.name
|
||||
form.isDefault = props.item.isDefault
|
||||
form.statuses = props.item.statuses.map(s => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
isFinal: s.isFinal,
|
||||
category: s.category,
|
||||
}))
|
||||
} else {
|
||||
form.name = ''
|
||||
form.isDefault = false
|
||||
form.statuses = []
|
||||
}
|
||||
touched.name = false
|
||||
})
|
||||
|
||||
function addStatus() {
|
||||
form.statuses.push({
|
||||
label: '',
|
||||
color: '#222783',
|
||||
position: form.statuses.length,
|
||||
isFinal: false,
|
||||
category: 'todo',
|
||||
})
|
||||
}
|
||||
|
||||
function removeStatus(idx: number) {
|
||||
form.statuses.splice(idx, 1)
|
||||
}
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
const statusService = useTaskStatusService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
if (!form.name.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
if (isEditing.value && props.item) {
|
||||
await workflowService.update(props.item.id, {
|
||||
name: form.name.trim(),
|
||||
isDefault: form.isDefault,
|
||||
position: props.item.position,
|
||||
})
|
||||
await syncStatuses(props.item)
|
||||
} else {
|
||||
const created = await workflowService.create({
|
||||
name: form.name.trim(),
|
||||
isDefault: form.isDefault,
|
||||
position: 0,
|
||||
})
|
||||
for (const s of form.statuses) {
|
||||
const payload: TaskStatusWrite = {
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
isFinal: s.isFinal,
|
||||
category: s.category,
|
||||
workflow: `/api/workflows/${created.id}`,
|
||||
}
|
||||
await statusService.create(payload)
|
||||
}
|
||||
}
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncStatuses(workflow: Workflow) {
|
||||
const existingIds = new Set(workflow.statuses.map(s => s.id))
|
||||
const keptIds = new Set<number>()
|
||||
|
||||
for (const s of form.statuses) {
|
||||
if (s.id) {
|
||||
keptIds.add(s.id)
|
||||
await statusService.update(s.id, {
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
isFinal: s.isFinal,
|
||||
category: s.category,
|
||||
})
|
||||
} else {
|
||||
await statusService.create({
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
isFinal: s.isFinal,
|
||||
category: s.category,
|
||||
workflow: `/api/workflows/${workflow.id}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of existingIds) {
|
||||
if (id && !keptIds.has(id)) {
|
||||
await statusService.remove(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -66,14 +66,10 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.description') }}
|
||||
</label>
|
||||
<textarea
|
||||
<MalioInputRichText
|
||||
v-model="editForm.description"
|
||||
rows="5"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
||||
:label="$t('clientTicket.description')"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -129,7 +125,13 @@
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
group-class="mt-1"
|
||||
/>
|
||||
<p v-else class="mt-1 text-sm italic text-neutral-400">—</p>
|
||||
</div>
|
||||
|
||||
<!-- URL (if bug) -->
|
||||
|
||||
@@ -116,7 +116,12 @@
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||
<div v-if="ticket.url" class="mt-2">
|
||||
<a
|
||||
:href="ticket.url"
|
||||
|
||||
@@ -87,10 +87,35 @@
|
||||
</MalioButton>
|
||||
</div>
|
||||
|
||||
<div v-if="props.project" class="mt-4 rounded border border-neutral-200 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs uppercase text-neutral-500">{{ $t('workflows.title') }}</p>
|
||||
<p class="text-sm font-semibold text-neutral-900">{{ props.project.workflow?.name }}</p>
|
||||
</div>
|
||||
<MalioButton
|
||||
v-if="canManageWorkflows"
|
||||
type="button"
|
||||
icon-name="mdi:swap-horizontal"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 py-1 text-xs"
|
||||
:label="$t('workflows.switchTitle')"
|
||||
@click="switchModalOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteProjectModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
|
||||
<ProjectWorkflowSwitchModal
|
||||
v-if="props.project"
|
||||
v-model="switchModalOpen"
|
||||
:project="props.project"
|
||||
@switched="onWorkflowSwitched"
|
||||
/>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
@@ -122,6 +147,15 @@ const isOpen = computed({
|
||||
const isEditing = computed(() => !!props.project)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
const switchModalOpen = ref(false)
|
||||
|
||||
const auth = useAuthStore()
|
||||
const canManageWorkflows = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
function onWorkflowSwitched() {
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const { listRepositories } = useGiteaService()
|
||||
const giteaRepos = ref<GiteaRepository[]>([])
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
/>
|
||||
</template>
|
||||
<template #cell-description="{ item }">
|
||||
{{ item.description ?? '—' }}
|
||||
{{ stripRichText(item.description) || '—' }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<MalioButton
|
||||
@@ -71,6 +71,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number
|
||||
|
||||
209
frontend/components/project/ProjectWorkflowSwitchModal.vue
Normal file
209
frontend/components/project/ProjectWorkflowSwitchModal.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="close" />
|
||||
<div class="relative z-10 w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('workflows.switchTitle') }}</h3>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5">
|
||||
<MalioSelect
|
||||
v-model="targetWorkflowId"
|
||||
:options="targetOptions"
|
||||
:label="$t('workflows.switchTargetLabel')"
|
||||
empty-option-label="—"
|
||||
min-width="!w-full"
|
||||
/>
|
||||
|
||||
<div v-if="targetWorkflow" class="flex flex-col gap-2">
|
||||
<h4 class="text-sm font-bold text-neutral-900">{{ $t('workflows.switchMappingTitle') }}</h4>
|
||||
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-neutral-500">
|
||||
<th class="py-2 pr-3">{{ $t('workflows.switchSourceCol') }}</th>
|
||||
<th class="py-2 pr-3">{{ $t('workflows.switchTargetCol') }}</th>
|
||||
<th class="py-2 text-right">{{ $t('workflows.switchTaskCountCol') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in mappingRows" :key="row.sourceId ?? 'backlog'" class="border-b last:border-0">
|
||||
<td class="py-2 pr-3">
|
||||
<span
|
||||
v-if="row.source"
|
||||
class="mr-2 inline-block h-3 w-3 rounded-full align-middle"
|
||||
:style="{ backgroundColor: row.source.color }"
|
||||
/>
|
||||
{{ row.source?.label ?? $t('myTasks.backlog') }}
|
||||
<span class="ml-1 text-xs text-neutral-400">
|
||||
({{ row.source?.category ? $t(`workflows.categories.${row.source.category}`) : '—' }})
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 pr-3">
|
||||
<select
|
||||
v-model="row.targetId"
|
||||
class="h-9 w-full rounded border border-neutral-300 px-2 text-sm"
|
||||
>
|
||||
<option :value="null">{{ $t('workflows.switchToBacklog') }}</option>
|
||||
<option
|
||||
v-for="s in targetWorkflow.statuses"
|
||||
:key="s.id"
|
||||
:value="s.id"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="py-2 text-right text-neutral-700">{{ row.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="$t('workflows.switchConfirm')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="!canConfirm || isSubmitting"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Workflow } from '~/services/dto/workflow'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
project: Project
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'switched'): void
|
||||
}>()
|
||||
|
||||
const workflows = ref<Workflow[]>([])
|
||||
const projectTasks = ref<Task[]>([])
|
||||
const targetWorkflowId = ref<number | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const targetOptions = computed(() =>
|
||||
workflows.value
|
||||
.filter(w => w.id !== props.project.workflow.id)
|
||||
.map(w => ({ label: w.name, value: w.id })),
|
||||
)
|
||||
|
||||
const targetWorkflow = computed<Workflow | null>(() =>
|
||||
workflows.value.find(w => w.id === targetWorkflowId.value) ?? null,
|
||||
)
|
||||
|
||||
type Row = {
|
||||
sourceId: number | null
|
||||
source: TaskStatus | null
|
||||
targetId: number | null
|
||||
count: number
|
||||
}
|
||||
|
||||
const mappingRows = ref<Row[]>([])
|
||||
|
||||
function smartPrefill(source: TaskStatus | null, target: Workflow): number | null {
|
||||
if (!source) return null
|
||||
const sameCat = target.statuses
|
||||
.filter(s => s.category === source.category)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
return sameCat[0]?.id ?? null
|
||||
}
|
||||
|
||||
watch(targetWorkflow, (tw) => {
|
||||
if (!tw) {
|
||||
mappingRows.value = []
|
||||
return
|
||||
}
|
||||
const usedStatusIds = new Map<number | null, number>()
|
||||
for (const t of projectTasks.value) {
|
||||
const key = t.status?.id ?? null
|
||||
usedStatusIds.set(key, (usedStatusIds.get(key) ?? 0) + 1)
|
||||
}
|
||||
mappingRows.value = [...usedStatusIds.entries()].map(([sourceId, count]) => {
|
||||
const source = props.project.workflow.statuses.find(s => s.id === sourceId) ?? null
|
||||
return {
|
||||
sourceId,
|
||||
source,
|
||||
targetId: smartPrefill(source, tw),
|
||||
count,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
if (!targetWorkflow.value) return false
|
||||
return mappingRows.value.every(r => r.sourceId === null || r.targetId !== undefined)
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (!open) return
|
||||
targetWorkflowId.value = null
|
||||
const [allWorkflows, tasks] = await Promise.all([
|
||||
workflowService.getAll(),
|
||||
taskService.getFiltered({ project: `/api/projects/${props.project.id}`, archived: false }),
|
||||
])
|
||||
workflows.value = allWorkflows
|
||||
projectTasks.value = tasks
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!targetWorkflow.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const mapping: Record<string, number | null> = {}
|
||||
for (const r of mappingRows.value) {
|
||||
if (r.sourceId !== null) {
|
||||
mapping[String(r.sourceId)] = r.targetId
|
||||
}
|
||||
}
|
||||
await workflowService.switchOnProject(props.project.id, {
|
||||
workflowId: targetWorkflow.value.id,
|
||||
mapping,
|
||||
})
|
||||
emit('switched')
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -14,8 +14,9 @@
|
||||
</span>
|
||||
|
||||
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
||||
<!-- Bulk status -->
|
||||
<!-- Bulk status (scoped to single project's workflow) -->
|
||||
<MalioSelect
|
||||
v-if="!isMultiProject"
|
||||
:model-value="null"
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
@@ -25,6 +26,13 @@
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="rounded border border-neutral-200 px-2 py-1 text-xs text-neutral-400"
|
||||
title="Sélection multi-projets — le statut dépend du workflow de chaque projet"
|
||||
>
|
||||
Status —
|
||||
</span>
|
||||
<!-- Bulk user -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
@@ -85,13 +93,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
selectedCount: number
|
||||
totalCount: number
|
||||
allSelected: boolean
|
||||
@@ -101,7 +111,12 @@ const props = defineProps<{
|
||||
priorities: TaskPriority[]
|
||||
efforts: TaskEffort[]
|
||||
groups: TaskGroup[]
|
||||
}>()
|
||||
selectedTasks?: Task[]
|
||||
projects?: Project[]
|
||||
}>(), {
|
||||
selectedTasks: () => [],
|
||||
projects: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-all'): void
|
||||
@@ -110,23 +125,42 @@ const emit = defineEmits<{
|
||||
(e: 'bulk-delete'): void
|
||||
}>()
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
const distinctProjectIds = computed(() => {
|
||||
const ids = new Set<number>()
|
||||
for (const t of props.selectedTasks) {
|
||||
if (t.project) ids.add(t.project.id)
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const isMultiProject = computed(() => distinctProjectIds.value.size > 1)
|
||||
|
||||
const statusOptions = computed<{ label: string, value: number }[]>(() => {
|
||||
// Si on connait les projets et qu'on est sur un seul, on scope au workflow de ce projet
|
||||
if (distinctProjectIds.value.size === 1 && props.projects.length > 0) {
|
||||
const projectId = [...distinctProjectIds.value][0]
|
||||
const project = props.projects.find(p => p.id === projectId)
|
||||
if (project?.workflow?.statuses) {
|
||||
return project.workflow.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
}
|
||||
}
|
||||
// Fallback : statuts globaux fournis en props (ex. depuis projects/[id])
|
||||
return props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
})
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
props.users.map(u => ({ label: u.username, value: u.id })),
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id })),
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id })),
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
||||
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<span
|
||||
v-if="showStatusBadge && task.status"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.status.color }"
|
||||
>
|
||||
{{ task.status.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
@@ -78,11 +85,17 @@
|
||||
class="text-blue-500"
|
||||
size="14"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="ml-auto h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
class="ml-auto"
|
||||
:class="task.collaborators?.length ? '' : 'ml-auto'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
@@ -100,8 +113,10 @@ import type { Task } from '~/services/dto/task'
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
showStatusBadge?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
showStatusBadge: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
min-height="120px"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
|
||||
@@ -86,17 +86,25 @@
|
||||
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="14" />
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||
>
|
||||
<Icon name="mdi:account-outline" size="14" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -170,15 +170,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collaborators -->
|
||||
<div v-if="collaboratorOptions.length" class="mt-5">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Collaborateurs</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="user in collaboratorOptions"
|
||||
:key="user.value"
|
||||
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
||||
:class="form.collaboratorIds.includes(user.value)
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:value="user.value"
|
||||
:checked="form.collaboratorIds.includes(user.value)"
|
||||
@change="toggleCollaborator(user.value)"
|
||||
/>
|
||||
{{ user.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-5">
|
||||
<MalioInputTextArea
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -544,6 +565,7 @@ const form = reactive({
|
||||
effortId: null as number | null,
|
||||
priorityId: null as number | null,
|
||||
assigneeId: null as number | null,
|
||||
collaboratorIds: [] as number[],
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
clientTicketId: null as number | null,
|
||||
@@ -586,6 +608,18 @@ const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const collaboratorOptions = computed(() =>
|
||||
props.users
|
||||
.filter(u => u.id !== form.assigneeId)
|
||||
.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
watch(() => form.assigneeId, (newAssigneeId) => {
|
||||
if (newAssigneeId) {
|
||||
form.collaboratorIds = form.collaboratorIds.filter(id => id !== newAssigneeId)
|
||||
}
|
||||
})
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
let filtered = props.groups.filter(g => !g.archived)
|
||||
if (showProjectSelect.value && form.projectId) {
|
||||
@@ -624,6 +658,12 @@ function toggleTag(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollaborator(userId: number) {
|
||||
const idx = form.collaboratorIds.indexOf(userId)
|
||||
if (idx >= 0) form.collaboratorIds.splice(idx, 1)
|
||||
else form.collaboratorIds.push(userId)
|
||||
}
|
||||
|
||||
const weekDays = computed(() => [
|
||||
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
||||
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
||||
@@ -648,6 +688,7 @@ function populateForm(task: Task | null) {
|
||||
form.effortId = task.effort?.id ?? null
|
||||
form.priorityId = task.priority?.id ?? null
|
||||
form.assigneeId = task.assignee?.id ?? null
|
||||
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
form.clientTicketId = task.clientTicket?.id ?? null
|
||||
@@ -694,6 +735,7 @@ function populateForm(task: Task | null) {
|
||||
form.effortId = null
|
||||
form.priorityId = null
|
||||
form.assigneeId = null
|
||||
form.collaboratorIds = []
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
@@ -906,6 +948,7 @@ async function handleSubmit() {
|
||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||
collaborators: form.collaboratorIds.map(id => `/api/users/${id}`),
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${resolvedProjectId.value}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
label="Libellé"
|
||||
input-class="w-full"
|
||||
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||
@blur="touched.label = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.position"
|
||||
label="Position"
|
||||
input-class="w-full"
|
||||
type="number"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<input
|
||||
id="isFinal"
|
||||
v-model="form.isFinal"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<label for="isFinal" class="text-sm font-medium text-neutral-700">
|
||||
{{ $t('archive.statusFinal') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus, TaskStatusWrite } from '~/services/dto/task-status'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskStatus | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
label: '',
|
||||
position: '0',
|
||||
color: '#222783',
|
||||
isFinal: false,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
label: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.label = props.item.label ?? ''
|
||||
form.position = String(props.item.position ?? 0)
|
||||
form.color = props.item.color ?? '#222783'
|
||||
form.isFinal = props.item.isFinal ?? false
|
||||
} else {
|
||||
form.label = ''
|
||||
form.position = '0'
|
||||
form.color = '#222783'
|
||||
form.isFinal = false
|
||||
}
|
||||
touched.label = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskStatusService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.label = true
|
||||
if (!form.label.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskStatusWrite = {
|
||||
label: form.label.trim(),
|
||||
position: Number(form.position),
|
||||
color: form.color,
|
||||
isFinal: form.isFinal,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.item) {
|
||||
await update(props.item.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -11,14 +11,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
min-height="120px"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span v-if="entry.project">{{ entry.project.name }}</span>
|
||||
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span>
|
||||
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
|
||||
<span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
|
||||
<span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
@click="ui.openMobileSidebar()"
|
||||
/>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
button-class="text-white/60 hover:bg-primary-600 hover:text-white"
|
||||
@click="toggleTitle"
|
||||
/>
|
||||
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:help-circle-outline"
|
||||
aria-label="Centre d'aide"
|
||||
variant="ghost"
|
||||
icon-size="22"
|
||||
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||
@click="navigateTo('/help')"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
|
||||
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||
@@ -66,13 +66,6 @@ defineProps<{
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
|
||||
|
||||
function toggleTitle() {
|
||||
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
|
||||
localStorage.setItem('appTitle', appTitle.value)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
|
||||
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="targetStatusId"
|
||||
:options="targetOptions"
|
||||
:label="$t('taskStatuses.moveTo')"
|
||||
:empty-option-label="$t('taskStatuses.backlog')"
|
||||
min-width="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancel"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('common.delete')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isProcessing"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
statusLabel: string
|
||||
taskCount: number
|
||||
availableStatuses: TaskStatus[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', targetStatusId: number | null): void
|
||||
}>()
|
||||
|
||||
const targetStatusId = ref<number | null>(null)
|
||||
const isProcessing = ref(false)
|
||||
|
||||
const targetOptions = computed(() =>
|
||||
props.availableStatuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
targetStatusId.value = null
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
isProcessing.value = true
|
||||
emit('confirm', targetStatusId.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="md-preview" appear>
|
||||
<div v-if="modelValue" class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
style="max-height: min(80vh, 700px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<h3 class="text-lg font-semibold text-slate-800">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="overflow-y-auto px-6 py-4">
|
||||
<div
|
||||
v-if="content"
|
||||
class="prose prose-slate max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:text-[0.875rem] [&_pre_code]:leading-relaxed"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-slate-400">
|
||||
Aucune description
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
content: string
|
||||
title?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
if (!props.content) return ''
|
||||
return marked.parse(props.content, { async: false }) as string
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.md-preview-enter-active,
|
||||
.md-preview-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.md-preview-enter-from,
|
||||
.md-preview-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -8,12 +8,11 @@
|
||||
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
||||
@blur="touched.username = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
label="Mot de passe"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||
:hint="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
||||
@blur="touched.password = true"
|
||||
/>
|
||||
|
||||
27
frontend/content/help/01-getting-started.md
Normal file
27
frontend/content/help/01-getting-started.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Bienvenue dans Lesstime
|
||||
|
||||
Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités :
|
||||
|
||||
- 🗂️ **Gestion de projets** avec kanban personnalisable (workflows)
|
||||
- ✅ **Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags
|
||||
- ⏱️ **Time tracking** intégré, lié aux projets et aux tâches
|
||||
- 🎫 **Portail client** pour que tes clients déposent leurs tickets
|
||||
|
||||
## Comprendre les rôles
|
||||
|
||||
| Rôle | Accès |
|
||||
|---|---|
|
||||
| **Admin** | Tout : projets, utilisateurs, intégrations, workflows |
|
||||
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
|
||||
| **Client** | Portal dédié — tickets sur ses projets uniquement |
|
||||
|
||||
## Vues principales
|
||||
|
||||
- **Dashboard** : vue d'ensemble personnelle (KPIs, tâches du jour)
|
||||
- **Mes tâches** : kanban perso groupé par catégorie, toutes projets confondus
|
||||
- **Projets** : un kanban par projet, statuts du workflow associé
|
||||
- **Time tracking** : timer, time entries, vue mois
|
||||
- **Admin** : gestion globale (visible uniquement par les admins)
|
||||
- **Portal** : interface dédiée aux utilisateurs ROLE_CLIENT
|
||||
|
||||
> 💡 **Astuce** : utilise l'avatar en haut à droite pour accéder à ton profil et y générer un **token MCP** (cf. section *Token MCP & API*) pour piloter Lesstime depuis Claude / Cursor.
|
||||
58
frontend/content/help/02-projects-workflows.md
Normal file
58
frontend/content/help/02-projects-workflows.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Projets & Workflows
|
||||
|
||||
## Qu'est-ce qu'un projet ?
|
||||
|
||||
Un projet regroupe un ensemble de **tâches**, **time entries** et éventuellement **tickets client**. Il est défini par :
|
||||
|
||||
- Un **code court** (2-10 lettres majuscules, ex: `SIRH`, `CRM`) qui préfixe les numéros de tâches
|
||||
- Un **client** optionnel (ou interne si null)
|
||||
- Une **couleur** d'identification
|
||||
- Un **workflow** (obligatoire) qui définit ses colonnes kanban
|
||||
|
||||
## Qu'est-ce qu'un workflow ?
|
||||
|
||||
Un **workflow** est un *jeu de statuts kanban* réutilisable. Au lieu d'avoir une liste globale de statuts comme dans la plupart des outils, chaque projet a son propre kanban adapté à sa façon de travailler.
|
||||
|
||||
### Exemple
|
||||
|
||||
| Workflow | Statuts |
|
||||
|---|---|
|
||||
| **Standard** (par défaut) | À faire → En cours → Bloqué → En attente de validation → Terminé |
|
||||
| **DevKanban** | Backlog → Spec → In Dev → Review PR → QA → Done |
|
||||
| **Support** | Nouveau → Diagnostic → Résolu |
|
||||
|
||||
Tu peux créer autant de workflows que tu veux depuis **Admin → Workflows**.
|
||||
|
||||
## Les 5 catégories canoniques
|
||||
|
||||
Chaque statut, peu importe son workflow, appartient à **une catégorie canonique** parmi :
|
||||
|
||||
| Catégorie | Description |
|
||||
|---|---|
|
||||
| `todo` | À faire — pas encore commencé |
|
||||
| `in_progress` | En cours — quelqu'un bosse dessus |
|
||||
| `blocked` | Bloqué — attente d'une dépendance |
|
||||
| `review` | En validation — relecture, PR, QA |
|
||||
| `done` | Terminé — close |
|
||||
|
||||
> 🎯 **Pourquoi des catégories ?** Pour que la vue *Mes tâches* puisse regrouper des tâches venant de projets avec des workflows différents (ex: une tâche "In Dev" de DevKanban et "En cours" de Standard apparaissent dans la même colonne `in_progress`).
|
||||
|
||||
## Changer le workflow d'un projet
|
||||
|
||||
1. Ouvrir le projet → **Modifier le projet** (drawer)
|
||||
2. Section **Workflow** → cliquer sur **Changer de workflow**
|
||||
3. Sélectionner le workflow cible
|
||||
4. **Mapper chaque statut source vers un statut cible** (le mapping est pré-rempli automatiquement par catégorie)
|
||||
5. **Confirmer** — toutes les tâches migrent dans une seule transaction
|
||||
|
||||
### Règles du mapping
|
||||
|
||||
- ✅ Chaque statut actuellement utilisé par une tâche **doit** être mappé (sinon erreur 422)
|
||||
- ✅ Un statut peut être mappé vers `null` → la tâche passe en backlog (sans statut)
|
||||
- ❌ Tu ne peux pas mapper vers un statut qui n'appartient pas au workflow cible
|
||||
|
||||
## Supprimer un workflow
|
||||
|
||||
Tu peux supprimer un workflow uniquement s'il n'est **lié à aucun projet** (HTTP 409 sinon). Réassigne d'abord les projets vers un autre workflow.
|
||||
|
||||
> ⚠️ Le workflow **Standard** ne peut pas être supprimé tant qu'il reste le défaut (un seul workflow peut avoir `isDefault=true` à la fois, garanti par un listener Doctrine).
|
||||
60
frontend/content/help/03-my-tasks.md
Normal file
60
frontend/content/help/03-my-tasks.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Mes tâches & Dashboard
|
||||
|
||||
## Vue *Mes tâches*
|
||||
|
||||
Accessible via la sidebar, c'est ta vue **transverse** : toutes les tâches dont tu es l'**assigné** ou un **collaborateur**, peu importe le projet.
|
||||
|
||||
### Deux modes d'affichage
|
||||
|
||||
#### 1. Kanban (par défaut)
|
||||
|
||||
Regroupé par les **5 catégories canoniques** :
|
||||
|
||||
```
|
||||
À faire → En cours → Bloqué → En validation → Terminé
|
||||
```
|
||||
|
||||
Chaque card affiche :
|
||||
- Le **code projet + numéro** (ex: `SIRH-12`) coloré selon le projet
|
||||
- Un **badge statut** (utile quand des tâches de projets différents cohabitent)
|
||||
- Priorité, tags, deadline, icônes (sync calendrier, récurrence, collaborateurs)
|
||||
- L'**avatar de l'assigné** + bouton timer (▶ / ⏹)
|
||||
|
||||
> 💡 Le **drag-to-status** est intentionnellement désactivé dans *Mes tâches* — pour changer un statut, ouvre la tâche (la valeur dépend du workflow du projet, pas de la catégorie).
|
||||
|
||||
#### 2. Liste
|
||||
|
||||
Vue tableau triable, avec **bulk actions** :
|
||||
- Cocher plusieurs tâches → barre d'actions en haut
|
||||
- Changer statut (désactivé si tâches de **projets différents**), assigné, priorité, effort, groupe
|
||||
- Supprimer en lot
|
||||
|
||||
### Filtres disponibles
|
||||
|
||||
| Filtre | Notes |
|
||||
|---|---|
|
||||
| **Projet** | Restreint à un projet précis |
|
||||
| **Groupe** | Disponible uniquement si un projet est sélectionné |
|
||||
| **Tag** | Tags globaux |
|
||||
| **Priorité / Effort** | |
|
||||
| **Assigné** | Par défaut : toi-même |
|
||||
|
||||
### Tri (vue liste uniquement)
|
||||
|
||||
- Par **deadline** (les plus proches en premier)
|
||||
- Par **scheduled start** (planification calendrier)
|
||||
|
||||
## Vue *Backlog*
|
||||
|
||||
Sous le kanban, les tâches **sans statut** apparaissent dans la section *Backlog*. Pratique pour les idées non encore qualifiées.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Le **dashboard** (page d'accueil après login) affiche :
|
||||
|
||||
- 📊 **KPIs personnels** : tâches en cours / à faire / en retard
|
||||
- 📈 **Charts** : répartition par statut, par priorité, time tracking cette semaine
|
||||
- 🔔 **Notifications** : assignations, commentaires (cf. cloche en topbar)
|
||||
- ⏱ **Timer actif** s'il y en a un
|
||||
|
||||
> 💡 Tu peux changer le filtre user du dashboard via le sélecteur en haut pour voir les KPIs d'un collègue (utile pour les leads).
|
||||
59
frontend/content/help/04-time-tracking.md
Normal file
59
frontend/content/help/04-time-tracking.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Time tracking
|
||||
|
||||
## Le timer
|
||||
|
||||
Le timer **flottant** est accessible depuis la sidebar ou directement depuis une tâche.
|
||||
|
||||
### Démarrer un timer
|
||||
|
||||
Trois façons :
|
||||
|
||||
1. **Depuis une TaskCard** : clique sur l'icône ▶ à droite de la card
|
||||
2. **Depuis le détail d'une tâche** : bouton *Démarrer le timer*
|
||||
3. **Manuellement** : depuis */time-tracking*, créer une time entry sans tâche
|
||||
|
||||
### Arrêter
|
||||
|
||||
- Clique sur ⏹ sur la card de la tâche en cours
|
||||
- Ou depuis la sidebar (icône timer pulsante en orange `#F18619`)
|
||||
|
||||
> 💡 Un seul timer actif à la fois. Démarrer un nouveau timer arrête automatiquement le précédent.
|
||||
|
||||
## Time entries
|
||||
|
||||
Chaque entrée a :
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| **Titre** | Description courte (ex: "Réunion daily") |
|
||||
| **Projet** | Obligatoire |
|
||||
| **Tâche** | Optionnel — lie l'entrée à une tâche précise |
|
||||
| **Tags** | Pour catégoriser (ex: "Backend", "Réunion") |
|
||||
| **Début / Fin** | Datetimes — la durée est calculée |
|
||||
| **User** | Qui a fait le travail |
|
||||
|
||||
### Vue *Time tracking*
|
||||
|
||||
Disponible en deux modes :
|
||||
|
||||
- **Vue semaine** : ligne par ligne, par jour
|
||||
- **Vue mois** : agrégation mensuelle, totaux par projet et par tag
|
||||
|
||||
### Filtres
|
||||
|
||||
- **Projet** (server-side)
|
||||
- **Tag** (server-side)
|
||||
- **User** (admin uniquement)
|
||||
- **Période** (date début / date fin)
|
||||
|
||||
## Édition
|
||||
|
||||
- Clique sur une time entry → drawer d'édition
|
||||
- Tu peux modifier projet, tâche, tags, dates a posteriori
|
||||
- La suppression est libre — pense à exporter avant si nécessaire
|
||||
|
||||
## Tags
|
||||
|
||||
Les tags sont **globaux** (partagés entre tous les projets, comme les statuts l'étaient avant les workflows). Définis depuis **Admin → Tags**.
|
||||
|
||||
> 📊 **Cas d'usage typique** : créer un tag par typologie d'activité (Dev, Réunion, Support, Veille) pour pouvoir agréger ton temps en fin de mois.
|
||||
62
frontend/content/help/05-tasks-detail.md
Normal file
62
frontend/content/help/05-tasks-detail.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Détail d'une tâche
|
||||
|
||||
## Champs principaux
|
||||
|
||||
| Champ | Notes |
|
||||
|---|---|
|
||||
| **Numéro** | Auto-incrémenté **par projet** (ex: `SIRH-1`, `SIRH-2`, `CRM-1`…) |
|
||||
| **Titre** | Obligatoire |
|
||||
| **Description** | Markdown supporté (preview disponible) |
|
||||
| **Statut** | Doit appartenir au workflow du projet (sinon erreur 422) |
|
||||
| **Priorité** | Basse / Moyenne / Haute — couleurs personnalisables |
|
||||
| **Effort** | S / M / L / XL / XXL — pour estimer la charge |
|
||||
| **Assigné** | Un seul user responsable |
|
||||
| **Collaborateurs** | Multiples — visibles via icône `mdi:account-group` |
|
||||
| **Groupe** | Optionnel — regroupe des tâches au sein d'un projet |
|
||||
| **Tags** | Globaux, plusieurs par tâche |
|
||||
| **Deadline** | Date — un badge coloré apparaît sur la card |
|
||||
| **Scheduled start / end** | Planification calendrier (sync optionnelle) |
|
||||
|
||||
## Récurrence
|
||||
|
||||
Une tâche peut être **récurrente** (icône 🔁 sur la card) :
|
||||
|
||||
- **Type** : quotidien, hebdomadaire, mensuel
|
||||
- **Intervalle** : tous les N jours/semaines/mois
|
||||
- **Jours de la semaine** (pour le mode hebdomadaire) : `monday`, `tuesday`, etc.
|
||||
|
||||
Chaque occurrence est gérée séparément ; cocher une tâche récurrente comme *Terminée* peut générer l'occurrence suivante selon le pattern.
|
||||
|
||||
## Sync calendrier
|
||||
|
||||
Si Zimbra est configuré (cf. Intégrations), tu peux activer **Sync calendrier** sur une tâche planifiée pour qu'elle apparaisse dans ton calendrier Zimbra (CalDav).
|
||||
|
||||
Icônes correspondantes :
|
||||
- 🟢 `mdi:calendar-check` → sync OK
|
||||
- 🔴 `mdi:alert-circle` → erreur de sync (passe sur l'icône pour le détail)
|
||||
|
||||
## Documents
|
||||
|
||||
Chaque tâche peut avoir des **documents attachés** (PDF, images, etc.) :
|
||||
|
||||
- Drag & drop dans la tâche pour uploader
|
||||
- Validation du **MIME type côté serveur** (pas seulement l'extension)
|
||||
- Téléchargement via lien dédié
|
||||
|
||||
## Liaison Gitea (si configuré)
|
||||
|
||||
Si le projet a un repo Gitea lié, tu peux :
|
||||
|
||||
- **Créer une branche** depuis la tâche : `feature/` `fix/` `refactor/` `hotfix/` `chore/` (5 types disponibles)
|
||||
- Convention de nommage : `<type>/<CODE>-<NUMBER>-<slug>` (ex: `feature/SIRH-12-add-login`)
|
||||
- **Voir les PRs** liées (état CI inclus)
|
||||
|
||||
## Liaison ticket client
|
||||
|
||||
Si la tâche découle d'un ticket client, l'icône 👤 (`heroicons:user-circle`) bleue apparaît avec le numéro du ticket (ex: `CT-001`).
|
||||
|
||||
## Commentaires & notifications
|
||||
|
||||
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
|
||||
- Les @mentions notifient l'utilisateur cité
|
||||
- La cloche en topbar (`NotificationBell`) liste toutes les notifications non lues
|
||||
43
frontend/content/help/06-client-portal.md
Normal file
43
frontend/content/help/06-client-portal.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Portal client
|
||||
|
||||
> 🎫 Section dédiée aux utilisateurs avec le rôle **ROLE_CLIENT**.
|
||||
|
||||
## Accès
|
||||
|
||||
Les utilisateurs *client* sont **automatiquement redirigés vers `/portal`** après login. Ils ne voient pas les vues internes (projets, time tracking, admin).
|
||||
|
||||
## Ce que voit un client
|
||||
|
||||
- 📋 La liste de ses **projets autorisés** (définis par l'admin dans le user)
|
||||
- 🎫 Sur chaque projet, la liste de ses **tickets** (ses créations uniquement)
|
||||
- ➕ Le bouton **Nouveau ticket** sur chaque projet
|
||||
|
||||
## Soumettre un ticket
|
||||
|
||||
Depuis `/portal/projects/<id>/new-ticket` :
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| **Type** | `bug` / `improvement` / `other` |
|
||||
| **Titre** | Court et descriptif |
|
||||
| **Description** | Détails — markdown supporté |
|
||||
| **URL** | Optionnel — page où le problème se manifeste |
|
||||
|
||||
Le ticket est automatiquement numéroté **par projet** (ex: `CT-001`).
|
||||
|
||||
## Statuts d'un ticket
|
||||
|
||||
| Statut | Visible côté client | Signification |
|
||||
|---|---|---|
|
||||
| `new` | Oui | Reçu, pas encore traité |
|
||||
| `in_progress` | Oui | Une tâche interne y est liée |
|
||||
| `done` | Oui | Résolu et clôturé |
|
||||
| `rejected` | Oui | Non retenu (avec commentaire explicatif) |
|
||||
|
||||
Le `statusComment` est visible par le client quand fourni.
|
||||
|
||||
## Côté équipe interne
|
||||
|
||||
- Les tickets apparaissent dans **Admin → Tickets client**
|
||||
- On peut **transformer un ticket en tâche** (la tâche garde une référence au ticket — icône 👤 bleue sur la card)
|
||||
- Le client voit l'avancement passer en `in_progress` automatiquement quand une tâche est liée
|
||||
66
frontend/content/help/07-admin.md
Normal file
66
frontend/content/help/07-admin.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Administration
|
||||
|
||||
> 🛡️ Section visible uniquement par les utilisateurs **ROLE_ADMIN**.
|
||||
|
||||
L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressource globale ou une intégration.
|
||||
|
||||
## Onglet *Clients*
|
||||
|
||||
- Liste des clients (entreprise / organisation)
|
||||
- Champs : nom, email, téléphone, adresse
|
||||
- Lier un client à des projets
|
||||
|
||||
## Onglet *Workflows*
|
||||
|
||||
⭐ **Nouveau** — remplace l'ancien onglet *Statuts*.
|
||||
|
||||
- Lister les workflows existants
|
||||
- **Créer un workflow** : nom, isDefault (un seul à la fois), liste de statuts éditables inline
|
||||
- Chaque statut : libellé, couleur, position, **catégorie** (5 valeurs canoniques), isFinal
|
||||
- **Éditer** un workflow modifie les statuts (sync intelligent : create / update / delete par diff)
|
||||
|
||||
> ⚠️ Supprimer un workflow lié à un projet renvoie une erreur **409**. Réassigne d'abord les projets.
|
||||
|
||||
## Onglet *Efforts*
|
||||
|
||||
- Tailles d'effort (S, M, L, XL, XXL)
|
||||
- Globales (partagées entre tous les projets)
|
||||
|
||||
## Onglet *Priorités*
|
||||
|
||||
- Niveaux de priorité (Basse, Moyenne, Haute) + couleur
|
||||
- Une priorité "Haute" affiche un drapeau rouge `mdi:flag-variant` sur la card
|
||||
|
||||
## Onglet *Tags*
|
||||
|
||||
- Tags globaux (tâches **et** time entries)
|
||||
- Couleur personnalisable
|
||||
- Pas de hiérarchie (flat list)
|
||||
|
||||
## Onglet *Utilisateurs*
|
||||
|
||||
- Créer / éditer / désactiver
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT`
|
||||
- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés*
|
||||
- Reset password depuis l'admin
|
||||
|
||||
> 🔐 Un user *admin+client* (les deux rôles) **n'est pas bloqué** par le middleware portal — le check est sur `ROLE_CLIENT && !ROLE_ADMIN`.
|
||||
|
||||
## Onglet *Gitea*
|
||||
|
||||
- URL serveur + token API
|
||||
- Lier un projet à un repo : `giteaOwner` + `giteaRepo`
|
||||
- Active les fonctionnalités branches / PRs sur les tâches
|
||||
|
||||
## Onglet *BookStack*
|
||||
|
||||
- URL + token API
|
||||
- Lier un projet à un **shelf** BookStack (`bookstackShelfId`)
|
||||
- Les tâches peuvent être liées à des pages BookStack (cf. `TaskBookStackLink`)
|
||||
|
||||
## Onglet *Zimbra*
|
||||
|
||||
- URL serveur + credentials (chiffrés via libsodium)
|
||||
- Configure le calendrier CalDav par défaut
|
||||
- Test de connexion intégré
|
||||
- Active la **sync calendrier** sur les tâches planifiées
|
||||
66
frontend/content/help/08-integrations.md
Normal file
66
frontend/content/help/08-integrations.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Intégrations
|
||||
|
||||
Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev.
|
||||
|
||||
## 🌳 Gitea
|
||||
|
||||
Lesstime parle à un serveur Gitea pour automatiser les conventions de branches et suivre les PRs.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Admin → Gitea** : URL serveur + token API
|
||||
2. Sur un projet : définir `giteaOwner` (org/user) et `giteaRepo` (nom du repo)
|
||||
|
||||
### Utilisation
|
||||
|
||||
Sur une tâche, le panneau Gitea propose :
|
||||
|
||||
- **Créer une branche** : choisir un type (`feature` / `fix` / `refactor` / `hotfix` / `chore`)
|
||||
- La branche est nommée automatiquement : `<type>/<PROJECT_CODE>-<NUMBER>-<slug-du-titre>`
|
||||
- **Lister les PRs liées** : par convention, toute PR qui contient `<PROJECT_CODE>-<NUMBER>` dans son nom ou sa description est reliée
|
||||
- **État CI** : ✅ ou ❌ affiché si le repo a des Actions/Workflows configurées
|
||||
|
||||
> 💡 La convention `<PROJECT_CODE>-<NUMBER>` permet à Gitea et Lesstime de se synchroniser **sans webhook** — juste par parsing des noms.
|
||||
|
||||
## 📚 BookStack
|
||||
|
||||
Lien tâche → documentation.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Admin → BookStack** : URL + token (token ID + token secret, chiffrés via libsodium)
|
||||
2. Sur un projet : définir `bookstackShelfId` + `bookstackShelfName`
|
||||
|
||||
### Utilisation
|
||||
|
||||
- Depuis une tâche : bouton **Lier à une page BookStack**
|
||||
- Sélectionner la page dans le shelf du projet
|
||||
- Le lien est bidirectionnel (BookStack peut afficher les tâches liées)
|
||||
|
||||
## 📅 Zimbra (CalDav)
|
||||
|
||||
Sync calendrier pour les tâches planifiées.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Admin → Zimbra** :
|
||||
- URL serveur (ex: `https://mail.ovh.com`)
|
||||
- Username (ex: `lesstime@ovh.fr`)
|
||||
- Password (chiffré côté serveur)
|
||||
- Calendar path (ex: `/dav/lesstime@ovh.fr/Calendar/`)
|
||||
- **Test de connexion** intégré
|
||||
2. Active la config (toggle `enabled`)
|
||||
|
||||
### Utilisation
|
||||
|
||||
Sur une tâche avec **scheduled start + end** :
|
||||
|
||||
1. Cocher **Sync calendrier**
|
||||
2. Au save, Lesstime crée/met à jour l'événement CalDav
|
||||
3. L'icône `mdi:calendar-check` (verte) apparaît sur la card si succès
|
||||
4. L'icône `mdi:alert-circle` (rouge) apparaît si erreur — passe dessus pour voir le détail
|
||||
|
||||
### Limites
|
||||
|
||||
- **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas
|
||||
- **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly)
|
||||
97
frontend/content/help/09-mcp-api.md
Normal file
97
frontend/content/help/09-mcp-api.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Token MCP & API
|
||||
|
||||
Lesstime expose un serveur **MCP** (Model Context Protocol) qui permet à un assistant IA (Claude, Cursor, etc.) de piloter ton instance Lesstime — créer des tâches, lire des projets, démarrer un timer, etc.
|
||||
|
||||
## Générer ton token
|
||||
|
||||
1. Va sur **Profil** (avatar → Profil)
|
||||
2. Section **Token MCP** → **Générer un token**
|
||||
3. **Copie le token immédiatement** — il ne sera plus affiché ensuite
|
||||
|
||||
> 🔐 **Sécurité** : Le token donne accès à toutes les actions de ton compte. Ne le partage jamais. Tu peux le régénérer à tout moment (l'ancien sera révoqué).
|
||||
|
||||
## Configurer Claude Code
|
||||
|
||||
Dans `.mcp.json` (à la racine de ton projet) :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"type": "http",
|
||||
"url": "https://ton-instance-lesstime/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer TON_TOKEN_ICI"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour une instance locale :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime-local": {
|
||||
"command": "docker",
|
||||
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tools disponibles (27 au total)
|
||||
|
||||
### Projets
|
||||
|
||||
- `list-projects`, `get-project`, `create-project`, `update-project`
|
||||
|
||||
### Tâches
|
||||
|
||||
- `list-tasks` (avec filtres : projet, assigné, statut, archived…)
|
||||
- `get-task`, `create-task`, `update-task`, `delete-task`
|
||||
|
||||
### Métadonnées
|
||||
|
||||
- `list-statuses` (param **`projectId`** optionnel — sans : tous les statuts ; avec : statuts du workflow du projet)
|
||||
- `list-priorities`, `list-efforts`, `list-tags`
|
||||
|
||||
### Workflows ⭐ Nouveau
|
||||
|
||||
- `list-workflows` — liste tous les workflows avec leurs statuts groupés
|
||||
- `switch-project-workflow` (ROLE_ADMIN) — change le workflow d'un projet avec mapping
|
||||
|
||||
### Time tracking
|
||||
|
||||
- `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry`
|
||||
|
||||
### Récurrence
|
||||
|
||||
- `create-task-recurrence`, `update-task-recurrence`, `delete-task-recurrence`
|
||||
|
||||
### Groupes / Users / Clients
|
||||
|
||||
- `list-groups`, `create-group`, `update-group`
|
||||
- `list-users`, `list-clients`
|
||||
|
||||
## Règles importantes
|
||||
|
||||
> ⚠️ **Statut hors workflow rejeté** : si tu appelles `create-task` ou `update-task` avec un `status` qui n'appartient pas au workflow du projet, l'appel est rejeté avec **422 Validation error**. Utilise `list-statuses(projectId)` pour découvrir les statuts valides du projet.
|
||||
|
||||
## Exemples de prompts
|
||||
|
||||
```
|
||||
"Crée une tâche dans Lesstime sur le projet SIRH avec le titre
|
||||
'Ajouter l'export PDF' et la priorité Haute, assignée à alice"
|
||||
```
|
||||
|
||||
```
|
||||
"Liste mes tâches en cours dans le projet CRM"
|
||||
```
|
||||
|
||||
```
|
||||
"Démarre un timer sur la tâche SIRH-12 avec le tag Backend"
|
||||
```
|
||||
|
||||
L'agent appelle les bons tools tout seul si la description est claire.
|
||||
@@ -56,6 +56,37 @@
|
||||
"moveTo": "Déplacer vers",
|
||||
"backlog": "Backlog (sans statut)"
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Workflows",
|
||||
"addWorkflow": "Ajouter un workflow",
|
||||
"editWorkflow": "Modifier le workflow",
|
||||
"name": "Nom",
|
||||
"isDefault": "Workflow par défaut",
|
||||
"statuses": "Statuts",
|
||||
"addStatus": "Ajouter un statut",
|
||||
"category": "Catégorie",
|
||||
"created": "Workflow créé",
|
||||
"updated": "Workflow mis à jour",
|
||||
"deleted": "Workflow supprimé",
|
||||
"switched": "Workflow du projet changé",
|
||||
"switchTitle": "Changer de workflow",
|
||||
"switchTargetLabel": "Nouveau workflow",
|
||||
"switchMappingTitle": "Mapping des statuts",
|
||||
"switchSourceCol": "Statut actuel",
|
||||
"switchTargetCol": "Statut cible",
|
||||
"switchTaskCountCol": "Tâches",
|
||||
"switchToBacklog": "Mapper vers le backlog",
|
||||
"switchConfirm": "Confirmer la migration",
|
||||
"switchSummary": "{count} tâche(s) migrée(s), projet sur workflow « {name} »",
|
||||
"deleteUsedBy": "Workflow utilisé par {count} projet(s) — impossible de supprimer.",
|
||||
"categories": {
|
||||
"todo": "À faire",
|
||||
"in_progress": "En cours",
|
||||
"blocked": "Bloqué",
|
||||
"review": "En validation",
|
||||
"done": "Terminé"
|
||||
}
|
||||
},
|
||||
"taskEfforts": {
|
||||
"created": "Effort créé avec succès.",
|
||||
"updated": "Effort mis à jour avec succès.",
|
||||
@@ -393,7 +424,21 @@
|
||||
"title": "Mon profil",
|
||||
"changeAvatar": "Changer l'avatar",
|
||||
"removeAvatar": "Supprimer l'avatar",
|
||||
"cropAvatar": "Recadrer l'avatar"
|
||||
"cropAvatar": "Recadrer l'avatar",
|
||||
"apiToken": {
|
||||
"title": "Token API MCP",
|
||||
"help": "Utilisé pour authentifier le serveur MCP HTTP (à coller dans le header Authorization: Bearer …). Ne pas partager.",
|
||||
"label": "Token",
|
||||
"empty": "Aucun token généré pour le moment.",
|
||||
"generate": "Générer un token",
|
||||
"regenerate": "Régénérer",
|
||||
"copy": "Copier",
|
||||
"copied": "Token copié dans le presse-papiers.",
|
||||
"copyFailed": "Impossible de copier le token.",
|
||||
"regenerated": "Nouveau token généré. L'ancien token est désormais invalide.",
|
||||
"confirmTitle": "Régénérer le token MCP ?",
|
||||
"confirmMessage": "L'ancien token sera immédiatement invalidé. Tous les clients MCP utilisant ce token devront être reconfigurés."
|
||||
}
|
||||
},
|
||||
"bookstack": {
|
||||
"settings": {
|
||||
|
||||
@@ -37,6 +37,9 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['markdown-it-task-lists'],
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
settings: {
|
||||
|
||||
921
frontend/package-lock.json
generated
921
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,15 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.2.0",
|
||||
"@malio/layer-ui": "^1.4.8",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"marked": "^18.0.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<div>
|
||||
<AdminClientTab v-if="activeTab === 'clients'" />
|
||||
<AdminStatusTab v-if="activeTab === 'statuses'" />
|
||||
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
|
||||
<AdminEffortTab v-if="activeTab === 'efforts'" />
|
||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||
@@ -40,7 +40,7 @@ useHead({ title: 'Administration' })
|
||||
|
||||
const tabs = [
|
||||
{ key: 'clients', label: 'Clients' },
|
||||
{ key: 'statuses', label: 'Statuts' },
|
||||
{ key: 'workflows', label: 'Workflows' },
|
||||
{ key: 'efforts', label: 'Efforts' },
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
|
||||
168
frontend/pages/help.vue
Normal file
168
frontend/pages/help.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
|
||||
useHead({ title: 'Aide' })
|
||||
|
||||
type Section = {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
accent: string
|
||||
roles: ('admin' | 'user' | 'client')[]
|
||||
content: string
|
||||
}
|
||||
|
||||
const rawModules = import.meta.glob('~/content/help/*.md', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
|
||||
|
||||
const META: Record<string, { title: string, icon: string, accent: string, roles: ('admin' | 'user' | 'client')[] }> = {
|
||||
'01-getting-started': { title: 'Bienvenue', icon: 'mdi:hand-wave', accent: 'from-amber-400 to-rose-500', roles: ['admin', 'user', 'client'] },
|
||||
'02-projects-workflows': { title: 'Projets & Workflows', icon: 'mdi:view-column-outline', accent: 'from-indigo-500 to-fuchsia-500', roles: ['admin', 'user'] },
|
||||
'03-my-tasks': { title: 'Mes tâches', icon: 'mdi:checkbox-marked-circle-outline', accent: 'from-sky-500 to-cyan-500', roles: ['admin', 'user'] },
|
||||
'04-time-tracking': { title: 'Time tracking', icon: 'mdi:timer-outline', accent: 'from-emerald-500 to-teal-500', roles: ['admin', 'user'] },
|
||||
'05-tasks-detail': { title: 'Tâches en détail', icon: 'mdi:file-document-edit-outline', accent: 'from-violet-500 to-purple-600', roles: ['admin', 'user'] },
|
||||
'06-client-portal': { title: 'Portal client', icon: 'mdi:account-tie-outline', accent: 'from-orange-500 to-amber-500', roles: ['admin', 'client'] },
|
||||
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
|
||||
'08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] },
|
||||
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||
}
|
||||
|
||||
const sections = computed<Section[]>(() => {
|
||||
return Object.entries(rawModules).map(([path, raw]) => {
|
||||
const id = path.split('/').pop()!.replace(/\.md$/, '')
|
||||
const meta = META[id] ?? { title: id, icon: 'mdi:file-document-outline', accent: 'from-neutral-500 to-neutral-700', roles: ['admin', 'user', 'client'] as ('admin' | 'user' | 'client')[] }
|
||||
return { id, ...meta, content: raw }
|
||||
}).sort((a, b) => a.id.localeCompare(b.id))
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
||||
const roles = auth.user?.roles ?? []
|
||||
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
||||
if (roles.includes('ROLE_CLIENT')) return 'client'
|
||||
return 'user'
|
||||
})
|
||||
|
||||
const visibleSections = computed(() =>
|
||||
sections.value.filter(s => s.roles.includes(userRole.value)),
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeId = ref(visibleSections.value[0]?.id ?? '')
|
||||
|
||||
onMounted(() => {
|
||||
const hash = (route.query.section as string) ?? route.hash.replace('#', '')
|
||||
if (hash && visibleSections.value.some(s => s.id === hash)) {
|
||||
activeId.value = hash
|
||||
}
|
||||
})
|
||||
|
||||
watch(activeId, (id) => {
|
||||
router.replace({ query: { ...route.query, section: id } })
|
||||
})
|
||||
|
||||
const activeSection = computed(() => visibleSections.value.find(s => s.id === activeId.value) ?? visibleSections.value[0])
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
if (!activeSection.value) return ''
|
||||
return marked.parse(activeSection.value.content, { async: false }) as string
|
||||
})
|
||||
|
||||
const prevSection = computed(() => {
|
||||
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||
return idx > 0 ? visibleSections.value[idx - 1] : null
|
||||
})
|
||||
|
||||
const nextSection = computed(() => {
|
||||
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||
return idx >= 0 && idx < visibleSections.value.length - 1 ? visibleSections.value[idx + 1] : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[calc(100vh-60px)] flex-col lg:flex-row">
|
||||
<!-- Sidebar -->
|
||||
<aside class="shrink-0 border-b border-neutral-200 bg-gradient-to-b from-white to-neutral-50 px-3 py-4 lg:w-72 lg:border-b-0 lg:border-r lg:px-4 lg:py-6">
|
||||
<div class="mb-4 flex items-center gap-2 lg:mb-6">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 text-white shadow-sm">
|
||||
<Icon name="mdi:lifebuoy" size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-base font-bold text-neutral-900">Centre d'aide</h1>
|
||||
<p class="text-xs text-neutral-500">Lesstime — Guide utilisateur</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-row gap-1 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0">
|
||||
<button
|
||||
v-for="section in visibleSections"
|
||||
:key="section.id"
|
||||
type="button"
|
||||
class="group flex shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium transition-all lg:shrink"
|
||||
:class="activeId === section.id
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-600 hover:bg-white hover:text-neutral-900'"
|
||||
@click="activeId = section.id"
|
||||
>
|
||||
<span
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br text-white shadow-sm"
|
||||
:class="section.accent"
|
||||
>
|
||||
<Icon :name="section.icon" size="16" />
|
||||
</span>
|
||||
<span class="whitespace-nowrap lg:whitespace-normal">{{ section.title }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex-1 px-4 py-6 sm:px-8 lg:px-12 lg:py-10">
|
||||
<div v-if="activeSection" class="mx-auto max-w-3xl">
|
||||
<!-- Hero header -->
|
||||
<div
|
||||
class="mb-8 overflow-hidden rounded-2xl bg-gradient-to-br p-6 text-white shadow-lg sm:p-8"
|
||||
:class="activeSection.accent"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm">
|
||||
<Icon :name="activeSection.icon" size="28" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-white/80">Section</p>
|
||||
<h2 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ activeSection.title }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown content -->
|
||||
<article
|
||||
class="prose prose-neutral max-w-none prose-headings:font-bold prose-headings:tracking-tight prose-h1:hidden prose-h2:mt-10 prose-h2:border-b prose-h2:border-neutral-200 prose-h2:pb-2 prose-h3:text-neutral-800 prose-a:text-primary-600 prose-strong:text-neutral-900 prose-code:rounded prose-code:bg-neutral-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:font-medium prose-code:text-rose-600 prose-code:before:content-none prose-code:after:content-none prose-pre:rounded-xl prose-pre:bg-slate-900 prose-table:border prose-table:border-neutral-200 prose-th:bg-neutral-50 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-amber-400 prose-blockquote:bg-amber-50 prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:not-italic prose-blockquote:text-amber-900"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
|
||||
<!-- Footer nav -->
|
||||
<div class="mt-12 flex items-center justify-between border-t border-neutral-200 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||
:disabled="!prevSection"
|
||||
@click="prevSection && (activeId = prevSection.id)"
|
||||
>
|
||||
<Icon name="mdi:arrow-left" size="18" />
|
||||
<span>{{ prevSection?.title ?? '' }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||
:disabled="!nextSection"
|
||||
@click="nextSection && (activeId = nextSection.id)"
|
||||
>
|
||||
<span>{{ nextSection?.title ?? '' }}</span>
|
||||
<Icon name="mdi:arrow-right" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -93,11 +93,22 @@ const isWeekPeriod = computed(() =>
|
||||
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
||||
)
|
||||
|
||||
// ── Filtered data (client-side project filter) ──
|
||||
// ── Filtered data (client-side project + user filter) ──
|
||||
|
||||
const effectiveUserId = computed(() => selectedUserId.value ?? auth.user?.id ?? null)
|
||||
|
||||
const tasks = computed(() => {
|
||||
if (!selectedProjectId.value) return allTasks.value
|
||||
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
|
||||
let result = allTasks.value
|
||||
if (selectedProjectId.value) {
|
||||
result = result.filter(t => t.project?.id === selectedProjectId.value)
|
||||
}
|
||||
if (selectedUserId.value) {
|
||||
result = result.filter(t =>
|
||||
t.assignee?.id === selectedUserId.value
|
||||
|| t.collaborators?.some(c => c.id === selectedUserId.value),
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const timeEntries = computed(() => {
|
||||
@@ -172,7 +183,10 @@ const totalHoursThisWeek = computed(() =>
|
||||
)
|
||||
|
||||
const myTasks = computed(() =>
|
||||
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
|
||||
tasks.value.filter(t =>
|
||||
t.assignee?.id === effectiveUserId.value
|
||||
|| t.collaborators?.some(c => c.id === effectiveUserId.value),
|
||||
)
|
||||
)
|
||||
|
||||
const myTasksDone = computed(() =>
|
||||
|
||||
@@ -17,24 +17,18 @@
|
||||
v-model="username"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputPassword
|
||||
v-model="password"
|
||||
label="Mot de passe"
|
||||
autocomplete="current-password"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioButton
|
||||
label="Se connecter"
|
||||
button-class="w-full"
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { StatusCategory } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
@@ -51,14 +53,16 @@ const selectedEffortId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
|
||||
// Sort
|
||||
type SortOption = 'default' | 'deadline' | 'scheduledStart'
|
||||
const sortBy = ref<SortOption>('default')
|
||||
const SORT_DEADLINE = 1
|
||||
const SORT_SCHEDULED = 2
|
||||
const sortById = ref<number | null>(null)
|
||||
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
// Bulk selection
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const selectedTasksArray = computed(() => tasks.value.filter(t => selectedTaskIds.has(t.id)))
|
||||
|
||||
// Modal
|
||||
const taskModalOpen = ref(false)
|
||||
@@ -106,13 +110,16 @@ const assigneeOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
// Kanban helpers
|
||||
const sortedStatuses = computed(() =>
|
||||
[...statuses.value].sort((a, b) => a.position - b.position)
|
||||
)
|
||||
const sortOptions = computed(() => [
|
||||
{ label: t('myTasks.sortDeadline'), value: SORT_DEADLINE },
|
||||
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
|
||||
])
|
||||
|
||||
function tasksByStatus(statusId: number): Task[] {
|
||||
return tasks.value.filter(t => t.status?.id === statusId)
|
||||
// Kanban helpers (grouped by canonical status category)
|
||||
const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
|
||||
|
||||
function tasksByCategory(category: StatusCategory): Task[] {
|
||||
return tasks.value.filter(t => t.status?.category === category)
|
||||
}
|
||||
|
||||
const backlogTasks = computed(() =>
|
||||
@@ -140,33 +147,43 @@ async function loadReferenceData() {
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const params: Record<string, string | number | boolean | string[]> = {
|
||||
const baseParams: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
params.assignee = `/api/users/${selectedAssigneeId.value}`
|
||||
}
|
||||
if (selectedProjectId.value) {
|
||||
params.project = `/api/projects/${selectedProjectId.value}`
|
||||
baseParams.project = `/api/projects/${selectedProjectId.value}`
|
||||
}
|
||||
if (selectedGroupId.value) {
|
||||
params.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
baseParams.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
baseParams.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
params.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
baseParams.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
baseParams['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
}
|
||||
if (sortBy.value === 'deadline') {
|
||||
params['order[deadline]'] = 'asc'
|
||||
} else if (sortBy.value === 'scheduledStart') {
|
||||
params['order[scheduledStart]'] = 'asc'
|
||||
if (sortById.value === SORT_DEADLINE) {
|
||||
baseParams['order[deadline]'] = 'asc'
|
||||
} else if (sortById.value === SORT_SCHEDULED) {
|
||||
baseParams['order[scheduledStart]'] = 'asc'
|
||||
}
|
||||
|
||||
if (selectedAssigneeId.value) {
|
||||
const userIri = `/api/users/${selectedAssigneeId.value}`
|
||||
const [assigneeTasks, collabTasks] = await Promise.all([
|
||||
taskService.getFiltered({ ...baseParams, assignee: userIri }),
|
||||
taskService.getFiltered({ ...baseParams, 'collaborators[]': userIri }),
|
||||
])
|
||||
const map = new Map<number, Task>()
|
||||
for (const t of assigneeTasks) map.set(t.id, t)
|
||||
for (const t of collabTasks) map.set(t.id, t)
|
||||
tasks.value = [...map.values()].sort((a, b) => b.id - a.id)
|
||||
} else {
|
||||
tasks.value = await taskService.getFiltered(baseParams)
|
||||
}
|
||||
tasks.value = await taskService.getFiltered(params)
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
@@ -180,7 +197,7 @@ async function loadAll() {
|
||||
|
||||
// Watch filters and sort to reload tasks
|
||||
watch(
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortById],
|
||||
() => { loadTasks() },
|
||||
)
|
||||
|
||||
@@ -189,44 +206,6 @@ watch(selectedProjectId, () => {
|
||||
selectedGroupId.value = null
|
||||
}, { flush: 'sync' })
|
||||
|
||||
// Drag & drop
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
|
||||
function onDragEnter(id: number) {
|
||||
dragCounter.value++
|
||||
dragOverStatusId.value = id
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragCounter.value--
|
||||
if (dragCounter.value === 0) {
|
||||
dragOverStatusId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragCounter.value = 0
|
||||
dragOverStatusId.value = null
|
||||
return Number(event.dataTransfer!.getData('text/plain'))
|
||||
}
|
||||
|
||||
async function onDropStatus(event: DragEvent, status: TaskStatus) {
|
||||
const taskId = onDrop(event)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task || task.status?.id === status.id) return
|
||||
task.status = status
|
||||
await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` })
|
||||
}
|
||||
|
||||
async function onDropBacklog(event: DragEvent) {
|
||||
const taskId = onDrop(event)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task || !task.status) return
|
||||
task.status = null
|
||||
await taskService.update(taskId, { status: null })
|
||||
}
|
||||
|
||||
// Modal
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
@@ -400,50 +379,41 @@ onMounted(async () => {
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
|
||||
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
|
||||
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<MalioSelect
|
||||
v-model="sortById"
|
||||
:options="sortOptions"
|
||||
:label="$t('myTasks.sortBy')"
|
||||
:empty-option-label="$t('myTasks.sortDefault')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban View -->
|
||||
<!-- Kanban View — grouped by canonical category -->
|
||||
<div v-if="viewMode === 'kanban'">
|
||||
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="status in sortedStatuses"
|
||||
:key="status.id"
|
||||
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat"
|
||||
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||
<div class="shrink-0 rounded-t-lg bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
|
||||
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
v-for="task in tasksByCategory(cat)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
show-status-badge
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
v-if="tasksByCategory(cat).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
@@ -453,15 +423,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backlog below kanban -->
|
||||
<div
|
||||
class="mt-8 rounded-lg p-4 transition-colors"
|
||||
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(0)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropBacklog($event)"
|
||||
>
|
||||
<!-- Backlog below kanban (no drag/drop — status change goes through TaskModal) -->
|
||||
<div class="mt-8 rounded-lg bg-neutral-50 p-4">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<TaskCard
|
||||
@@ -469,6 +432,7 @@ onMounted(async () => {
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
show-status-badge
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
@@ -493,6 +457,8 @@ onMounted(async () => {
|
||||
:priorities="priorities"
|
||||
:efforts="efforts"
|
||||
:groups="groups"
|
||||
:selected-tasks="selectedTasksArray"
|
||||
:projects="projects"
|
||||
@toggle-all="toggleSelectAll(tasks)"
|
||||
@bulk-update="onBulkUpdate"
|
||||
@bulk-archive="onBulkArchive"
|
||||
|
||||
@@ -37,15 +37,10 @@
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<MalioInputTextArea
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-resize-width="100%"
|
||||
max-resize-width="100%"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,6 +37,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Token MCP (interne uniquement) -->
|
||||
<div
|
||||
v-if="!isClientOnly"
|
||||
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
|
||||
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
|
||||
|
||||
<div v-if="auth.user?.apiToken">
|
||||
<MalioInputPassword
|
||||
:model-value="auth.user.apiToken"
|
||||
:label="$t('profile.apiToken.label')"
|
||||
readonly
|
||||
@update:model-value="() => {}"
|
||||
/>
|
||||
<div class="mt-3 flex flex-wrap gap-3">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:content-copy"
|
||||
icon-position="left"
|
||||
:label="$t('profile.apiToken.copy')"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:refresh"
|
||||
icon-position="left"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.regenerate')"
|
||||
@click="showConfirm = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="w-auto px-4"
|
||||
icon-name="mdi:key-plus"
|
||||
icon-position="left"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.generate')"
|
||||
@click="onRegenerate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Crop modal -->
|
||||
<AvatarCropper
|
||||
v-if="selectedFile"
|
||||
@@ -44,14 +94,45 @@
|
||||
@crop="onCrop"
|
||||
@cancel="selectedFile = null"
|
||||
/>
|
||||
|
||||
<!-- Confirm regenerate modal -->
|
||||
<Teleport v-if="showConfirm" to="body">
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ $t('profile.apiToken.confirmMessage') }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('common.cancel')"
|
||||
@click="showConfirm = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="regenerating"
|
||||
:label="$t('profile.apiToken.regenerate')"
|
||||
@click="onRegenerate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAvatarService } from '~/composables/useAvatarService'
|
||||
import { useApiTokenService } from '~/services/api-token'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
@@ -61,9 +142,12 @@ definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
const { upload, remove } = useAvatarService()
|
||||
const { regenerate } = useApiTokenService()
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const removing = ref(false)
|
||||
const regenerating = ref(false)
|
||||
const showConfirm = ref(false)
|
||||
|
||||
function onFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
@@ -97,4 +181,28 @@ async function onRemove() {
|
||||
removing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopy() {
|
||||
if (!auth.user?.apiToken) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(auth.user.apiToken)
|
||||
toast.success({ message: t('profile.apiToken.copied') })
|
||||
} catch {
|
||||
toast.error({ message: t('profile.apiToken.copyFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerate() {
|
||||
regenerating.value = true
|
||||
try {
|
||||
const newToken = await regenerate()
|
||||
if (auth.user) {
|
||||
auth.user.apiToken = newToken
|
||||
}
|
||||
showConfirm.value = false
|
||||
toast.success({ message: t('profile.apiToken.regenerated') })
|
||||
} finally {
|
||||
regenerating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -82,7 +82,6 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
@@ -96,7 +95,6 @@ useHead({ title: 'Archives' })
|
||||
|
||||
const projectService = useProjectService()
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
@@ -105,8 +103,11 @@ const userService = useUserService()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const archivedTasks = ref<Task[]>([])
|
||||
const statuses = ref<TaskStatus[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
|
||||
const statuses = computed<TaskStatus[]>(() =>
|
||||
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
|
||||
)
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
@@ -126,10 +127,9 @@ const filteredTasks = computed(() => {
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
|
||||
const [p, t, e, pr, ty, g, u] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
taskService.getByProject(projectId.value, true),
|
||||
statusService.getAll(),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
@@ -138,7 +138,6 @@ async function loadData() {
|
||||
])
|
||||
project.value = p
|
||||
archivedTasks.value = t
|
||||
statuses.value = s
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = ty
|
||||
|
||||
@@ -84,7 +84,12 @@
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
||||
<MalioInputRichText
|
||||
v-if="ticket.description"
|
||||
:model-value="ticket.description"
|
||||
:editable="false"
|
||||
/>
|
||||
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||
<div v-if="ticket.url" class="mt-2">
|
||||
<a
|
||||
:href="ticket.url"
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="viewMode === 'list'"
|
||||
v-model="selectedStatusId"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
@@ -217,7 +218,6 @@ import type { Client } from '~/services/dto/client'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
@@ -233,7 +233,6 @@ useHead({ title: 'Projet' })
|
||||
const projectService = useProjectService()
|
||||
const clientService = useClientService()
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
@@ -242,7 +241,6 @@ const userService = useUserService()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const tasks = ref<Task[]>([])
|
||||
const statuses = ref<TaskStatus[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
@@ -251,6 +249,10 @@ const users = ref<UserData[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const statuses = computed<TaskStatus[]>(() =>
|
||||
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
|
||||
)
|
||||
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(null)
|
||||
@@ -258,6 +260,12 @@ const selectedStatusId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'kanban') {
|
||||
selectedStatusId.value = null
|
||||
}
|
||||
})
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
@@ -298,7 +306,10 @@ const filteredTasks = computed(() => {
|
||||
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
result = result.filter(t => t.assignee?.id === selectedAssigneeId.value)
|
||||
result = result.filter(t =>
|
||||
t.assignee?.id === selectedAssigneeId.value
|
||||
|| t.collaborators?.some(c => c.id === selectedAssigneeId.value)
|
||||
)
|
||||
}
|
||||
if (selectedStatusId.value) {
|
||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||
@@ -323,10 +334,9 @@ const backlogTasks = computed(() =>
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([
|
||||
const [p, t, e, pr, ty, g, u, c] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
taskService.getByProject(projectId.value),
|
||||
statusService.getAll(),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
@@ -336,7 +346,6 @@ async function loadData() {
|
||||
])
|
||||
project.value = p
|
||||
tasks.value = t
|
||||
statuses.value = s
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = ty
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
empty-option-label="Tous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -217,16 +217,7 @@ function updatePageHeaderHeight() {
|
||||
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
||||
}
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
let result = entries.value
|
||||
if (selectedProjectId.value) {
|
||||
result = result.filter((e) => e.project?.id === selectedProjectId.value)
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
||||
}
|
||||
return result
|
||||
})
|
||||
const filteredEntries = computed(() => entries.value)
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
const date = new Date(d)
|
||||
@@ -239,15 +230,35 @@ function getMonday(d: Date): Date {
|
||||
|
||||
function navigatePrev() {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(d.getDate() - (viewMode.value === 'day' ? 1 : 7))
|
||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
||||
if (viewMode.value === 'day') {
|
||||
d.setDate(d.getDate() - 1)
|
||||
startDate.value = d
|
||||
} else if (viewMode.value === 'list') {
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
d.setDate(d.getDate() - 7)
|
||||
startDate.value = getMonday(d)
|
||||
}
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
||||
if (viewMode.value === 'day') {
|
||||
d.setDate(d.getDate() + 1)
|
||||
startDate.value = d
|
||||
} else if (viewMode.value === 'list') {
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
d.setDate(d.getDate() + 7)
|
||||
startDate.value = getMonday(d)
|
||||
}
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
@@ -359,12 +370,20 @@ async function onExport(params: {
|
||||
|
||||
async function loadEntries() {
|
||||
const end = new Date(startDate.value)
|
||||
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||
if (viewMode.value === 'day') {
|
||||
end.setDate(end.getDate() + 1)
|
||||
} else if (viewMode.value === 'list') {
|
||||
end.setMonth(end.getMonth() + 1)
|
||||
} else {
|
||||
end.setDate(end.getDate() + 7)
|
||||
}
|
||||
|
||||
entries.value = await timeEntryService.getByDateRange({
|
||||
after: startDate.value.toISOString(),
|
||||
before: end.toISOString(),
|
||||
user: selectedUserId.value ?? undefined,
|
||||
project: selectedProjectId.value ?? undefined,
|
||||
tag: selectedTagId.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,11 +419,20 @@ onMounted(async () => {
|
||||
|
||||
watch(viewMode, () => {
|
||||
selectedDateFilter.value = null
|
||||
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
|
||||
if (viewMode.value === 'day') {
|
||||
// keep current date
|
||||
} else if (viewMode.value === 'list') {
|
||||
const d = new Date(startDate.value)
|
||||
d.setDate(1)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
startDate.value = d
|
||||
} else {
|
||||
startDate.value = getMonday(startDate.value)
|
||||
}
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
watch(selectedUserId, () => {
|
||||
watch([selectedUserId, selectedProjectId, selectedTagId], () => {
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
|
||||
12
frontend/services/api-token.ts
Normal file
12
frontend/services/api-token.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function useApiTokenService() {
|
||||
const api = useApi()
|
||||
|
||||
async function regenerate(): Promise<string> {
|
||||
const data = await api.post<{ apiToken: string }>('/me/regenerate-api-token', {}, {
|
||||
toast: false,
|
||||
})
|
||||
return data.apiToken
|
||||
}
|
||||
|
||||
return { regenerate }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Client } from './client'
|
||||
import type { Workflow } from './workflow'
|
||||
|
||||
export type Project = {
|
||||
id: number
|
||||
@@ -8,6 +9,7 @@ export type Project = {
|
||||
description: string | null
|
||||
color: string
|
||||
client: Client | null
|
||||
workflow: Workflow
|
||||
giteaOwner: string | null
|
||||
giteaRepo: string | null
|
||||
bookstackShelfId: number | null
|
||||
@@ -22,6 +24,7 @@ export type ProjectWrite = {
|
||||
description: string | null
|
||||
color: string
|
||||
client: string | null // IRI : "/api/clients/1" ou null
|
||||
workflow?: string // IRI : "/api/workflows/1"
|
||||
giteaOwner?: string | null
|
||||
giteaRepo?: string | null
|
||||
bookstackShelfId?: number | null
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { StatusCategory } from './workflow'
|
||||
|
||||
export type TaskStatus = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
@@ -5,6 +7,8 @@ export type TaskStatus = {
|
||||
color: string
|
||||
position: number
|
||||
isFinal: boolean
|
||||
category: StatusCategory
|
||||
workflow?: { '@id': string, id: number } | string
|
||||
}
|
||||
|
||||
export type TaskStatusWrite = {
|
||||
@@ -12,4 +16,6 @@ export type TaskStatusWrite = {
|
||||
color: string
|
||||
position: number
|
||||
isFinal: boolean
|
||||
category: StatusCategory
|
||||
workflow?: string
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export type Task = {
|
||||
effort: TaskEffort | null
|
||||
priority: TaskPriority | null
|
||||
assignee: UserData | null
|
||||
collaborators: UserData[]
|
||||
group: TaskGroup | null
|
||||
project: Project | null
|
||||
tags: TaskTag[]
|
||||
@@ -55,6 +56,7 @@ export type TaskWrite = {
|
||||
effort: string | null
|
||||
priority: string | null
|
||||
assignee: string | null
|
||||
collaborators?: string[]
|
||||
group: string | null
|
||||
project: string
|
||||
tags: string[]
|
||||
|
||||
@@ -8,6 +8,7 @@ export type UserData = {
|
||||
client?: { id: number; name: string } | null
|
||||
allowedProjects?: Project[]
|
||||
avatarUrl?: string | null
|
||||
apiToken?: string | null
|
||||
}
|
||||
|
||||
export type UserWrite = {
|
||||
|
||||
27
frontend/services/dto/workflow.ts
Normal file
27
frontend/services/dto/workflow.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { TaskStatus, TaskStatusWrite } from './task-status'
|
||||
|
||||
export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'
|
||||
|
||||
export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
|
||||
todo: 'À faire',
|
||||
in_progress: 'En cours',
|
||||
blocked: 'Bloqué',
|
||||
review: 'En validation',
|
||||
done: 'Terminé',
|
||||
}
|
||||
|
||||
export type Workflow = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
name: string
|
||||
isDefault: boolean
|
||||
position: number
|
||||
statuses: TaskStatus[]
|
||||
}
|
||||
|
||||
export type WorkflowWrite = {
|
||||
name: string
|
||||
isDefault: boolean
|
||||
position: number
|
||||
statuses?: TaskStatusWrite[]
|
||||
}
|
||||
@@ -9,7 +9,8 @@ export function useTimeEntryService() {
|
||||
after: string
|
||||
before: string
|
||||
user?: number
|
||||
types?: number[]
|
||||
project?: number
|
||||
tag?: number
|
||||
}): Promise<TimeEntry[]> {
|
||||
const query: Record<string, unknown> = {
|
||||
'startedAt[after]': params.after,
|
||||
@@ -18,6 +19,12 @@ export function useTimeEntryService() {
|
||||
if (params.user) {
|
||||
query.user = `/api/users/${params.user}`
|
||||
}
|
||||
if (params.project) {
|
||||
query.project = `/api/projects/${params.project}`
|
||||
}
|
||||
if (params.tag) {
|
||||
query['tags[]'] = `/api/task_tags/${params.tag}`
|
||||
}
|
||||
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
55
frontend/services/workflows.ts
Normal file
55
frontend/services/workflows.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Workflow, WorkflowWrite } from './dto/workflow'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
type SwitchPayload = {
|
||||
workflowId: number
|
||||
mapping: Record<string, number | null>
|
||||
}
|
||||
|
||||
type SwitchResult = {
|
||||
projectId: number
|
||||
workflowId: number
|
||||
migratedTaskCount: number
|
||||
}
|
||||
|
||||
export function useWorkflowService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<Workflow[]> {
|
||||
const data = await api.get<HydraCollection<Workflow>>('/workflows')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getOne(id: number): Promise<Workflow> {
|
||||
return api.get<Workflow>(`/workflows/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: WorkflowWrite): Promise<Workflow> {
|
||||
return api.post<Workflow>('/workflows', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'workflows.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<WorkflowWrite>): Promise<Workflow> {
|
||||
return api.patch<Workflow>(`/workflows/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'workflows.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/workflows/${id}`, {}, {
|
||||
toastSuccessKey: 'workflows.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
async function switchOnProject(projectId: number, payload: SwitchPayload): Promise<SwitchResult> {
|
||||
return api.post<SwitchResult>(
|
||||
`/projects/${projectId}/switch-workflow`,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
{ toastSuccessKey: 'workflows.switched' },
|
||||
)
|
||||
}
|
||||
|
||||
return { getAll, getOne, create, update, remove, switchOnProject }
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {Config} from 'tailwindcss'
|
||||
import typography from '@tailwindcss/typography'
|
||||
|
||||
export default <Partial<Config>>{
|
||||
darkMode: 'class',
|
||||
plugins: [typography],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
@@ -3,3 +3,17 @@ export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
||||
}
|
||||
|
||||
export function stripRichText(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return value
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'|'/gi, '\'')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -59,8 +59,9 @@ RUN ln -sf /dev/stdout /var/log/nginx/access.log \
|
||||
RUN rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# Configs
|
||||
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/lesstime.conf
|
||||
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/lesstime.conf
|
||||
COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
|
||||
|
||||
# Backend from stage 1
|
||||
COPY --from=backend-build /app /var/www/html
|
||||
@@ -72,7 +73,7 @@ COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.
|
||||
RUN echo "APP_ENV=prod" > /var/www/html/.env
|
||||
|
||||
# Permissions
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads \
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads /var/www/html/var/mcp-sessions \
|
||||
&& chown -R www-data:www-data /var/www/html/var
|
||||
|
||||
WORKDIR /var/www/html
|
||||
38
infra/prod/deploy.sh
Executable file
38
infra/prod/deploy.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export LESSTIME_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying lesstime:${TAG}..."
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
sudo docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
sudo docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Extracting maintenance page..."
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
|
||||
echo "==> Running migrations..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
echo "==> Disabling maintenance mode..."
|
||||
rm -f maintenance.on
|
||||
|
||||
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
@@ -4,10 +4,14 @@ services:
|
||||
container_name: lesstime-app
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||
- ./uploads:/var/www/html/var/uploads
|
||||
- lesstime_logs:/var/www/html/var/log
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
lesstime_logs:
|
||||
49
infra/prod/maintenance.html
Normal file
49
infra/prod/maintenance.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maintenance en cours</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
|
||||
padding: 48px 40px;
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
font-size: 24px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">🛠</div>
|
||||
<h1>Maintenance en cours</h1>
|
||||
<p>L'application est temporairement indisponible pour mise à jour. Elle sera de retour dans quelques instants.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
31
infra/prod/nginx-proxy.conf
Normal file
31
infra/prod/nginx-proxy.conf
Normal file
@@ -0,0 +1,31 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name project.malio-dev.fr;
|
||||
|
||||
root /var/www/lesstime/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/lesstime/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 55m;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,23 @@ server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/html/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
root /var/www/html/public;
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
root /var/www/html/public;
|
||||
internal;
|
||||
}
|
||||
|
||||
root /var/www/html/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
6
makefile
6
makefile
@@ -1,6 +1,6 @@
|
||||
# Permet d'utiliser un .env.docker.local pour override
|
||||
ENV_DEFAULT = docker/.env.docker
|
||||
ENV_LOCAL = docker/.env.docker.local
|
||||
ENV_DEFAULT = infra/dev/.env.docker
|
||||
ENV_LOCAL = infra/dev/.env.docker.local
|
||||
ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT))
|
||||
|
||||
# Permet d'avoir les variables du fichier .env.docker.local
|
||||
@@ -23,13 +23,11 @@ FILES =
|
||||
#========================================================================================
|
||||
|
||||
env-init:
|
||||
@mkdir -p docker
|
||||
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
|
||||
|
||||
# Lance le container
|
||||
start: env-init
|
||||
@echo "**** START CONTAINERS ****"
|
||||
@cp --update=none docker/.env.docker docker/.env.docker.local
|
||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||
|
||||
# Éteint le container
|
||||
|
||||
37
migrations/Version20260409075411.php
Normal file
37
migrations/Version20260409075411.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260409075411 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE task_collaborator (task_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (task_id, user_id))');
|
||||
$this->addSql('CREATE INDEX IDX_A8FC6C518DB60186 ON task_collaborator (task_id)');
|
||||
$this->addSql('CREATE INDEX IDX_A8FC6C51A76ED395 ON task_collaborator (user_id)');
|
||||
$this->addSql('ALTER TABLE task_collaborator ADD CONSTRAINT FK_A8FC6C518DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE task_collaborator ADD CONSTRAINT FK_A8FC6C51A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE task_collaborator DROP CONSTRAINT FK_A8FC6C518DB60186');
|
||||
$this->addSql('ALTER TABLE task_collaborator DROP CONSTRAINT FK_A8FC6C51A76ED395');
|
||||
$this->addSql('DROP TABLE task_collaborator');
|
||||
}
|
||||
}
|
||||
36
migrations/Version20260519175041.php
Normal file
36
migrations/Version20260519175041.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260519175041 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create workflow table and seed default Standard workflow';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE workflow (
|
||||
id SERIAL NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
is_default BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_workflow_name ON workflow (name)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_workflow_one_default ON workflow (is_default) WHERE is_default = TRUE');
|
||||
|
||||
$this->addSql("INSERT INTO workflow (name, is_default, position) VALUES ('Standard', TRUE, 0)");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE workflow');
|
||||
}
|
||||
}
|
||||
74
migrations/Version20260519175114.php
Normal file
74
migrations/Version20260519175114.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
use Doctrine\Migrations\Exception\MigrationException;
|
||||
|
||||
final class Version20260519175114 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Attach existing TaskStatus rows to Standard workflow and backfill category (strict mapping)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1) Récupérer l'id du workflow Standard
|
||||
$standardId = $this->connection->fetchOne("SELECT id FROM workflow WHERE name = 'Standard'");
|
||||
if (!$standardId) {
|
||||
throw new MigrationException('Workflow Standard introuvable. Lancer M1 d\'abord.');
|
||||
}
|
||||
|
||||
// 2) Garde-fou : vérifier qu'il n'y a pas de label hors mapping
|
||||
$mapping = [
|
||||
'A faire' => 'todo',
|
||||
'À faire' => 'todo',
|
||||
'En cours' => 'in_progress',
|
||||
'Bloqué' => 'blocked',
|
||||
'En attente de validation' => 'review',
|
||||
'Terminé' => 'done',
|
||||
];
|
||||
$rows = $this->connection->fetchAllAssociative('SELECT id, label FROM task_status');
|
||||
foreach ($rows as $row) {
|
||||
if (!isset($mapping[$row['label']])) {
|
||||
throw new MigrationException(sprintf(
|
||||
'TaskStatus #%d ("%s") n\'est pas mappable. Ajoutez son mapping dans la migration avant de relancer.',
|
||||
$row['id'],
|
||||
$row['label'],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Ajouter colonnes nullable
|
||||
$this->addSql('ALTER TABLE task_status ADD COLUMN workflow_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task_status ADD COLUMN category VARCHAR(32) DEFAULT NULL');
|
||||
|
||||
// 4) Backfill
|
||||
$this->addSql("UPDATE task_status SET workflow_id = {$standardId}");
|
||||
foreach ($mapping as $label => $cat) {
|
||||
$this->addSql(sprintf(
|
||||
"UPDATE task_status SET category = '%s' WHERE label = '%s'",
|
||||
$cat,
|
||||
str_replace("'", "''", $label),
|
||||
));
|
||||
}
|
||||
|
||||
// 5) NOT NULL + FK
|
||||
$this->addSql('ALTER TABLE task_status ALTER COLUMN workflow_id SET NOT NULL');
|
||||
$this->addSql('ALTER TABLE task_status ALTER COLUMN category SET NOT NULL');
|
||||
$this->addSql('ALTER TABLE task_status ADD CONSTRAINT FK_task_status_workflow FOREIGN KEY (workflow_id) REFERENCES workflow (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_task_status_workflow ON task_status (workflow_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE task_status DROP CONSTRAINT FK_task_status_workflow');
|
||||
$this->addSql('DROP INDEX IDX_task_status_workflow');
|
||||
$this->addSql('ALTER TABLE task_status DROP COLUMN workflow_id');
|
||||
$this->addSql('ALTER TABLE task_status DROP COLUMN category');
|
||||
}
|
||||
}
|
||||
38
migrations/Version20260519175142.php
Normal file
38
migrations/Version20260519175142.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
use Doctrine\Migrations\Exception\MigrationException;
|
||||
|
||||
final class Version20260519175142 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Attach existing projects to Standard workflow (NOT NULL, RESTRICT)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$standardId = $this->connection->fetchOne("SELECT id FROM workflow WHERE name = 'Standard'");
|
||||
if (!$standardId) {
|
||||
throw new MigrationException('Workflow Standard introuvable.');
|
||||
}
|
||||
|
||||
$this->addSql('ALTER TABLE project ADD COLUMN workflow_id INT DEFAULT NULL');
|
||||
$this->addSql("UPDATE project SET workflow_id = {$standardId}");
|
||||
$this->addSql('ALTER TABLE project ALTER COLUMN workflow_id SET NOT NULL');
|
||||
$this->addSql('ALTER TABLE project ADD CONSTRAINT FK_project_workflow FOREIGN KEY (workflow_id) REFERENCES workflow (id) ON DELETE RESTRICT NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_project_workflow ON project (workflow_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE project DROP CONSTRAINT FK_project_workflow');
|
||||
$this->addSql('DROP INDEX IDX_project_workflow');
|
||||
$this->addSql('ALTER TABLE project DROP COLUMN workflow_id');
|
||||
}
|
||||
}
|
||||
38
migrations/Version20260519175338.php
Normal file
38
migrations/Version20260519175338.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260519175338 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Align workflow schema with Doctrine naming (indexes, IDENTITY). Drops the partial unique index uniq_workflow_one_default (uniqueness is enforced by UniqueDefaultWorkflowListener at app level).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER INDEX idx_project_workflow RENAME TO IDX_2FB3D0EE2C7C2CBA');
|
||||
$this->addSql('ALTER INDEX idx_task_status_workflow RENAME TO IDX_40A9E1CF2C7C2CBA');
|
||||
$this->addSql('DROP INDEX uniq_workflow_one_default');
|
||||
$this->addSql('ALTER TABLE workflow ALTER id DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE workflow ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
|
||||
// Aligner la séquence d'identity sur MAX(id) pour éviter le conflit avec les rows déjà insérés par M1
|
||||
$this->addSql('SELECT setval(pg_get_serial_sequence(\'workflow\', \'id\'), COALESCE((SELECT MAX(id) FROM workflow), 0) + 1, false)');
|
||||
$this->addSql('ALTER INDEX uniq_workflow_name RENAME TO UNIQ_65C598165E237E06');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER INDEX idx_2fb3d0ee2c7c2cba RENAME TO idx_project_workflow');
|
||||
$this->addSql('ALTER INDEX idx_40a9e1cf2c7c2cba RENAME TO idx_task_status_workflow');
|
||||
$this->addSql('ALTER TABLE workflow ALTER id DROP IDENTITY');
|
||||
$this->addSql("ALTER TABLE workflow ALTER id SET DEFAULT nextval('workflow_id_seq'::regclass)");
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_workflow_one_default ON workflow (is_default) WHERE (is_default = true)');
|
||||
$this->addSql('ALTER INDEX uniq_65c598165e237e06 RENAME TO uniq_workflow_name');
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: ./script/deploy-release.sh v0.1.0
|
||||
# Requires: curl, tar, (optional) rsync
|
||||
#
|
||||
# Auth token: set RELEASE_TOKEN env var or create /etc/lesstime-release-token
|
||||
umask 002
|
||||
|
||||
TAG="${1:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "Usage: $0 v0.1.0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_OWNER="MALIO-DEV"
|
||||
REPO_NAME="Lesstime"
|
||||
GITEA_API="https://gitea.malio.fr/api/v1"
|
||||
DEPLOY_DIR="/var/www/lesstime"
|
||||
|
||||
if [ -f /etc/lesstime-release-token ] && [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
RELEASE_TOKEN="$(cat /etc/lesstime-release-token)"
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
release_json="$tmp_dir/release.json"
|
||||
curl_opts=(-sS)
|
||||
if [ -n "${RELEASE_TOKEN:-}" ]; then
|
||||
curl_opts+=(-H "Authorization: token ${RELEASE_TOKEN}")
|
||||
fi
|
||||
curl "${curl_opts[@]}" \
|
||||
"${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" \
|
||||
-o "$release_json"
|
||||
|
||||
asset_url="$(python3 - "$release_json" <<'PY'
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], 'r'))
|
||||
assets = data.get("assets", [])
|
||||
for a in assets:
|
||||
name = a.get("name", "")
|
||||
if name.startswith("lesstime-") and name.endswith(".tar.gz"):
|
||||
print(a.get("browser_download_url", ""))
|
||||
break
|
||||
PY
|
||||
)"
|
||||
|
||||
if [ -z "$asset_url" ]; then
|
||||
echo "Release asset not found for tag ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
archive="$tmp_dir/artefact.tar.gz"
|
||||
curl "${curl_opts[@]}" -L "$asset_url" -o "$archive"
|
||||
|
||||
tar -xzf "$archive" -C "$tmp_dir"
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --delete --no-perms --no-owner --no-group \
|
||||
--exclude ".env" \
|
||||
--exclude ".env.local" \
|
||||
--exclude "config/jwt" \
|
||||
--exclude "var" \
|
||||
"$tmp_dir"/ "$DEPLOY_DIR"/
|
||||
else
|
||||
cp -a "$tmp_dir"/. "$DEPLOY_DIR"/
|
||||
fi
|
||||
|
||||
# Ensure Nginx can traverse the deploy path.
|
||||
chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
|
||||
|
||||
# Create frontend/dist symlink if needed (nginx serves from frontend/dist)
|
||||
if [ -d "${DEPLOY_DIR}/frontend/.output/public" ] && [ ! -L "${DEPLOY_DIR}/frontend/dist" ]; then
|
||||
ln -sfn "${DEPLOY_DIR}/frontend/.output/public" "${DEPLOY_DIR}/frontend/dist"
|
||||
fi
|
||||
|
||||
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
|
||||
|
||||
# Ensure var/log exists and is writable by PHP (www-data)
|
||||
mkdir -p "${DEPLOY_DIR}/var/log"
|
||||
chown www-data:www-data "${DEPLOY_DIR}/var/log"
|
||||
chmod 775 "${DEPLOY_DIR}/var/log"
|
||||
|
||||
if [ -f "${DEPLOY_DIR}/.env.local" ]; then
|
||||
echo "Clearing cache..."
|
||||
php "${DEPLOY_DIR}/bin/console" cache:clear --env=prod --no-debug
|
||||
|
||||
echo "Running migrations (if any)..."
|
||||
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
|
||||
else
|
||||
echo "Skip post-deploy: ${DEPLOY_DIR}/.env.local not found" >&2
|
||||
fi
|
||||
26
src/ApiResource/SwitchWorkflowOutput.php
Normal file
26
src/ApiResource/SwitchWorkflowOutput.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
final class SwitchWorkflowOutput
|
||||
{
|
||||
#[Groups(['switch_workflow:read'])]
|
||||
public int $projectId;
|
||||
|
||||
#[Groups(['switch_workflow:read'])]
|
||||
public int $workflowId;
|
||||
|
||||
#[Groups(['switch_workflow:read'])]
|
||||
public int $migratedTaskCount;
|
||||
|
||||
public function __construct(int $projectId, int $workflowId, int $migratedTaskCount)
|
||||
{
|
||||
$this->projectId = $projectId;
|
||||
$this->workflowId = $workflowId;
|
||||
$this->migratedTaskCount = $migratedTaskCount;
|
||||
}
|
||||
}
|
||||
36
src/Controller/RegenerateApiTokenController.php
Normal file
36
src/Controller/RegenerateApiTokenController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
use function bin2hex;
|
||||
use function random_bytes;
|
||||
|
||||
class RegenerateApiTokenController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/api/me/regenerate-api-token', name: 'me_regenerate_api_token', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$user->setApiToken($token);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['apiToken' => $token]);
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,10 @@ use App\Entity\TaskStatus;
|
||||
use App\Entity\TaskTag;
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Entity\User;
|
||||
use App\Entity\Workflow;
|
||||
use App\Entity\ZimbraConfiguration;
|
||||
use App\Enum\RecurrenceType;
|
||||
use App\Enum\StatusCategory;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
@@ -86,57 +88,31 @@ class AppFixtures extends Fixture
|
||||
$clientNova->setPostalCode('69007');
|
||||
$manager->persist($clientNova);
|
||||
|
||||
// Projets
|
||||
$projectSirh = new Project();
|
||||
$projectSirh->setCode('SIRH');
|
||||
$projectSirh->setName('SIRH');
|
||||
$projectSirh->setDescription('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer ac blandit turpis.');
|
||||
$projectSirh->setColor('#222783');
|
||||
$projectSirh->setClient($clientLiot);
|
||||
$manager->persist($projectSirh);
|
||||
// Workflow par défaut
|
||||
$standardWorkflow = new Workflow();
|
||||
$standardWorkflow->setName('Standard');
|
||||
$standardWorkflow->setIsDefault(true);
|
||||
$standardWorkflow->setPosition(0);
|
||||
$manager->persist($standardWorkflow);
|
||||
|
||||
$projectCrm = new Project();
|
||||
$projectCrm->setCode('CRM');
|
||||
$projectCrm->setName('CRM');
|
||||
$projectCrm->setDescription('Gestion de la relation client et suivi commercial.');
|
||||
$projectCrm->setColor('#E91E63');
|
||||
$projectCrm->setClient($clientAcme);
|
||||
$manager->persist($projectCrm);
|
||||
|
||||
$projectErp = new Project();
|
||||
$projectErp->setCode('ERP');
|
||||
$projectErp->setName('ERP');
|
||||
$projectErp->setDescription('Planification des ressources et gestion des stocks.');
|
||||
$projectErp->setColor('#4A90D9');
|
||||
$projectErp->setClient($clientNova);
|
||||
$manager->persist($projectErp);
|
||||
|
||||
$projectInterne = new Project();
|
||||
$projectInterne->setCode('SITE');
|
||||
$projectInterne->setName('Site vitrine');
|
||||
$projectInterne->setDescription('Refonte du site web corporate.');
|
||||
$projectInterne->setColor('#26A69A');
|
||||
$projectInterne->setClient(null);
|
||||
$manager->persist($projectInterne);
|
||||
|
||||
// Task Statuses (global)
|
||||
// Task Statuses (rattachés au workflow Standard)
|
||||
$defaultStatuses = [
|
||||
['A faire', '#222783', 0],
|
||||
['En cours', '#4A90D9', 1],
|
||||
['Bloqué', '#C62828', 2],
|
||||
['En attente de validation', '#FF8F00', 3],
|
||||
['Terminé', '#26A69A', 4],
|
||||
['A faire', '#222783', 0, StatusCategory::Todo, false],
|
||||
['En cours', '#4A90D9', 1, StatusCategory::InProgress, false],
|
||||
['Bloqué', '#C62828', 2, StatusCategory::Blocked, false],
|
||||
['En attente de validation', '#FF8F00', 3, StatusCategory::Review, false],
|
||||
['Terminé', '#26A69A', 4, StatusCategory::Done, true],
|
||||
];
|
||||
|
||||
$statusObjects = [];
|
||||
foreach ($defaultStatuses as [$label, $color, $position]) {
|
||||
foreach ($defaultStatuses as [$label, $color, $position, $category, $isFinal]) {
|
||||
$status = new TaskStatus();
|
||||
$status->setLabel($label);
|
||||
$status->setColor($color);
|
||||
$status->setPosition($position);
|
||||
if ('Terminé' === $label) {
|
||||
$status->setIsFinal(true);
|
||||
}
|
||||
$status->setCategory($category);
|
||||
$status->setIsFinal($isFinal);
|
||||
$standardWorkflow->addStatus($status);
|
||||
$manager->persist($status);
|
||||
$statusObjects[$label] = $status;
|
||||
}
|
||||
@@ -147,6 +123,43 @@ class AppFixtures extends Fixture
|
||||
$statusReview = $statusObjects['En attente de validation'];
|
||||
$statusDone = $statusObjects['Terminé'];
|
||||
|
||||
// Projets
|
||||
$projectSirh = new Project();
|
||||
$projectSirh->setCode('SIRH');
|
||||
$projectSirh->setName('SIRH');
|
||||
$projectSirh->setDescription('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer ac blandit turpis.');
|
||||
$projectSirh->setColor('#222783');
|
||||
$projectSirh->setClient($clientLiot);
|
||||
$projectSirh->setWorkflow($standardWorkflow);
|
||||
$manager->persist($projectSirh);
|
||||
|
||||
$projectCrm = new Project();
|
||||
$projectCrm->setCode('CRM');
|
||||
$projectCrm->setName('CRM');
|
||||
$projectCrm->setDescription('Gestion de la relation client et suivi commercial.');
|
||||
$projectCrm->setColor('#E91E63');
|
||||
$projectCrm->setClient($clientAcme);
|
||||
$projectCrm->setWorkflow($standardWorkflow);
|
||||
$manager->persist($projectCrm);
|
||||
|
||||
$projectErp = new Project();
|
||||
$projectErp->setCode('ERP');
|
||||
$projectErp->setName('ERP');
|
||||
$projectErp->setDescription('Planification des ressources et gestion des stocks.');
|
||||
$projectErp->setColor('#4A90D9');
|
||||
$projectErp->setClient($clientNova);
|
||||
$projectErp->setWorkflow($standardWorkflow);
|
||||
$manager->persist($projectErp);
|
||||
|
||||
$projectInterne = new Project();
|
||||
$projectInterne->setCode('SITE');
|
||||
$projectInterne->setName('Site vitrine');
|
||||
$projectInterne->setDescription('Refonte du site web corporate.');
|
||||
$projectInterne->setColor('#26A69A');
|
||||
$projectInterne->setClient(null);
|
||||
$projectInterne->setWorkflow($standardWorkflow);
|
||||
$manager->persist($projectInterne);
|
||||
|
||||
// Task Efforts
|
||||
$effortS = new TaskEffort();
|
||||
$effortS->setLabel('S');
|
||||
|
||||
@@ -10,9 +10,12 @@ use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\ApiResource\SwitchWorkflowOutput;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\State\SwitchProjectWorkflowProcessor;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -30,6 +33,19 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Post(
|
||||
uriTemplate: '/projects/{id}/switch-workflow',
|
||||
uriVariables: ['id' => new Link(fromClass: Project::class)],
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
input: false,
|
||||
output: SwitchWorkflowOutput::class,
|
||||
normalizationContext: ['groups' => ['switch_workflow:read']],
|
||||
processor: SwitchProjectWorkflowProcessor::class,
|
||||
read: true,
|
||||
deserialize: false,
|
||||
validate: false,
|
||||
name: 'switch_workflow',
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['project:read']],
|
||||
denormalizationContext: ['groups' => ['project:write']],
|
||||
@@ -69,6 +85,12 @@ class Project
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private ?Client $client = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Workflow::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
|
||||
private ?Workflow $workflow = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||
private ?string $giteaOwner = null;
|
||||
@@ -228,6 +250,18 @@ class Project
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWorkflow(): ?Workflow
|
||||
{
|
||||
return $this->workflow;
|
||||
}
|
||||
|
||||
public function setWorkflow(Workflow $workflow): static
|
||||
{
|
||||
$this->workflow = $workflow;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['project:read'])]
|
||||
public function getTaskCount(): int
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user