Compare commits

..

48 Commits

Author SHA1 Message Date
595b8f5be3 Merge pull request 'feat(workflow) : workflows de statuts par projet (kanban custom)' (#3) from feat/project-workflows into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 3m47s
Reviewed-on: #3
2026-05-20 07:44:09 +00:00
5fb7fbe66c fix(workflow) : M4 - aligne la séquence workflow.id après recréation de l'identity (évite conflit avec row Standard de M1) 2026-05-19 20:59:37 +02:00
c1560468e6 fix(workflow) : WorkflowDrawer - input position natif (MalioInputText n'accepte pas les number) 2026-05-19 20:59:37 +02:00
f86698e7cd docs(workflows) : plan d'implémentation + validations Matthieu sur le spec + gitignore dumps locaux 2026-05-19 20:59:37 +02:00
1fd2c05db3 chore : bump version to v0.4.0 2026-05-19 20:59:37 +02:00
9f179e400d feat(workflow) : MCP - list-statuses projectId + list-workflows + switch-project-workflow + maj descriptions create/update-task 2026-05-19 20:59:12 +02:00
6a37349cf7 feat(workflow) : bulk status désactivé sur sélection multi-projets, scoped au workflow du projet 2026-05-19 20:59:12 +02:00
52b78d6bbc feat(workflow) : ProjectWorkflowSwitchModal + section workflow et bouton switch dans ProjectDrawer 2026-05-19 20:59:12 +02:00
e6d765f7bb feat(workflow) : my-tasks - kanban groupé par catégorie avec badge statut, suppression drag-to-status 2026-05-19 20:59:12 +02:00
5d42009348 feat(workflow) : kanban projet et archives basés sur workflow.statuses du projet 2026-05-19 20:59:12 +02:00
8e4ddf00a8 feat(workflow) : admin UI - WorkflowDrawer + AdminWorkflowTab + remplacement onglet Statuts, suppression composants obsolètes 2026-05-19 20:59:12 +02:00
18bc96082f feat(workflow) : DTOs front Workflow + category sur TaskStatus + workflow embarqué sur Project + service + i18n 2026-05-19 20:59:12 +02:00
6a084489ea feat(workflow) : endpoint POST /projects/{id}/switch-workflow + processor transactionnel 2026-05-19 20:59:12 +02:00
80a41db34f feat(workflow) : protège la suppression d'un workflow lié à des projets (409) 2026-05-19 20:59:12 +02:00
cf94635121 feat(workflow) : valide que task.status appartient au workflow du projet 2026-05-19 20:59:12 +02:00
eec61c089c feat(workflow) : migration M4 - alignement schéma Doctrine (indexes + IDENTITY) 2026-05-19 20:59:12 +02:00
a9f87be8e5 feat(workflow) : listener garantissant un seul workflow isDefault=true 2026-05-19 20:59:12 +02:00
25f2fc4b16 feat(workflow) : fixtures - workflow Standard + statuts catégorisés + projets attachés 2026-05-19 20:59:12 +02:00
a21914312a feat(workflow) : migration M3 - workflow requis sur Project (RESTRICT) 2026-05-19 20:59:12 +02:00
f6a947ec15 feat(workflow) : migration M2 - rattache les statuts existants à Standard + category 2026-05-19 20:59:12 +02:00
03f3c85fd8 feat(workflow) : migration M1 - création table workflow + seed Standard 2026-05-19 20:59:12 +02:00
8a68e0d397 feat(workflow) : ajoute workflow requis sur Project (RESTRICT) 2026-05-19 20:59:12 +02:00
43e6d1aed2 feat(workflow) : ajoute workflow et category sur TaskStatus 2026-05-19 20:59:12 +02:00
a3e3fd6da6 feat(workflow) : ajoute l'entité Workflow et son repository 2026-05-19 20:59:12 +02:00
b8b03048b6 feat(workflow) : ajoute l'enum StatusCategory (5 catégories canoniques) 2026-05-19 20:59:12 +02:00
Matthieu
ba86a71e12 docs(workflows) : ajout note de reprise sur autre poste
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:59:12 +02:00
Matthieu
6a942def3f docs(workflows) : spec workflows de statuts par projet
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:59:12 +02:00
gitea-actions
d4fdb84a17 chore: bump version to v0.3.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 19s
2026-05-13 14:23:42 +00:00
Matthieu
5585fa7ef6 fix(mcp) : exclude DataFixtures from discovery to avoid require-dev autoload error in prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
2026-05-13 16:23:35 +02:00
gitea-actions
b301ebbad0 chore: bump version to v0.3.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 52s
2026-05-13 12:59:31 +00:00
Matthieu
feaa9f1875 feat(api-token) : génération du token MCP depuis la page profil
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend :
- POST /api/me/regenerate-api-token : nouveau controller, ROLE_USER (exclut CLIENT)
- User.apiToken exposé via groupe me:read sur GET /api/me

Frontend :
- Section 'Token API MCP' sur /profile (masquée pour les CLIENT du portail)
- Boutons Copier + Régénérer avec modal de confirmation
- Service api-token + DTO mis à jour + clés i18n fr
2026-05-13 14:59:18 +02:00
gitea-actions
b25be8fd6a chore: bump version to v0.3.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 43s
2026-05-06 13:58:46 +00:00
Matthieu
3e6b0e877a fix(time-tracking) : filtres projet/tag server-side et vue liste au mois
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Pousse les filtres projet et tag a l'API (au lieu d'un filtrage client-side
  partiel sur la page courante) pour eviter les resultats incomplets en cas
  de pagination
- Ajoute les watchers selectedProjectId/selectedTagId qui declenchent un reload
- Mode liste : navigation et plage de chargement passent a 1 mois (au lieu
  d'une fenetre de 7 jours qui rendait le mode liste inutilisable)
- Renomme l'option vide du filtre User en "Tous" (etait "User", ambigu)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:51:18 +02:00
Matthieu
9f3fc05a52 fix(project) : masquer le filtre status en mode kanban
En mode kanban, selectionner un statut dans le filtre Status vidait toutes
les autres colonnes ET le backlog (tasks?.status?.id !== selectedId) : le
filtre etait redondant avec les colonnes et cassait la vue.

Conditionne l'affichage du filtre Status a viewMode === 'list' et reset le
filtre lors du retour en kanban.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:51:09 +02:00
Matthieu
4c3721b6ac fix(dashboard) : appliquer le filtre user aux KPIs et charts de taches
Avant, seul le KPI "Heures sur la periode" reagissait au filtre Utilisateur ;
"Taches totales", "Mes taches actives" et tous les graphiques tache restaient
inchanges. Le computed tasks ne filtrait que par projet, et myTasks etait
hardcode sur auth.user.id (cf ticket LST40).

Ajoute un effectiveUserId (selectedUser ?? auth.user) et applique le filtre
user a tasks pour propager dans tous les charts et KPIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:51:02 +02:00
Matthieu
06d733f88e docs : ajoute note delegation Codex pour taches mecaniques 2026-05-06 08:49:20 +02:00
gitea-actions
258c6e9c17 chore: bump version to v0.3.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 1m10s
2026-05-04 18:54:31 +00:00
feffe63019 fix(rich-text) : nettoyer deps TipTap obsolètes et fixer interop CJS
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Le rich text editor étant désormais fourni par @malio/layer-ui, les
dépendances @tiptap/* et tiptap-markdown directes dans Lesstime
(héritage de l'ancien éditeur local) ne servent plus et causaient un
doublon de tiptap-markdown (0.8.10 + 0.9.0) qui faisait planter
l'init Nuxt avec une erreur d'export default sur markdown-it-task-lists.

- Suppression des deps @tiptap/extension-link, @tiptap/extension-placeholder,
  @tiptap/pm, @tiptap/starter-kit, @tiptap/vue-3, tiptap-markdown
- Ajout de markdown-it-task-lists à vite.optimizeDeps.include pour
  forcer Vite à gérer correctement l'interop CJS du module

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 20:54:18 +02:00
34ba554fba chore : bump @malio/layer-ui à 1.4.8
Inclut les couleurs de texte et surlignage façon Jira dans
<MalioInputRichText> (toolbar étendue avec popover en palette).

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 20:47:17 +02:00
b2cc6e96e1 fix(rich-text) : strip HTML pour les contextes plain-text
Avec MalioInputRichText qui émet désormais du HTML par défaut,
plusieurs points d'affichage rendaient les balises brutes au
lieu du texte. Ajoute un helper stripRichText() (frontend) et
descriptionToPlainText() (backend) pour neutraliser ces cas.

- TimeEntryList : strip avant truncate dans la liste des time
  entries.
- ProjectGroupTab : strip dans la cellule description du
  tableau des groupes.
- CalDavService : strip_tags + html_entity_decode avant injection
  dans le DESCRIPTION VEVENT/VTODO iCal (sinon Outlook/Apple
  Calendar affichaient les <p>...</p> à l'utilisateur).

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 19:55:23 +02:00
2a68d2f9c6 feat(rich-text) : migrer vers MalioInputRichText (layer-ui 1.4.7)
Remplace les éditeurs markdown locaux et les textareas
description par <MalioInputRichText> (TipTap v3 + StarterKit +
tiptap-markdown) du paquet @malio/layer-ui.

Sites migrés :
- TaskModal (description tâche)
- TaskGroupDrawer (description groupe de tâches)
- TimeEntryDrawer (description time entry)
- ClientTicketDetailModal (édition + lecture seule)
- ProjectClientTickets (panneau admin lecture seule)
- new-ticket (formulaire portail client)
- client-tickets (vue admin lecture seule)

Stockage en BDD inchangé : le markdown existant est parsé à
l'ouverture, le composant émet du HTML par défaut sur les
sauvegardes (migration lazy au fil des éditions).

Bumpe @malio/layer-ui de ^1.2.3 à ^1.4.7 et ajoute les
dépendances TipTap utilisées par le composant.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 19:54:57 +02:00
2898b22440 fix(infra) : monter nginx.conf comme default.conf
Avant, deux fichiers conf cohabitaient dans /etc/nginx/conf.d/
(default.conf de l'image + lesstime.conf monté), tous deux écoutant
sur :80 server_name localhost. Nginx prenait default.conf
(ordre alphabétique), ce qui faisait répondre 404 à toutes les
requêtes /api/* — donc pas de header CORS, donc le navigateur
remontait une erreur CORS trompeuse côté front.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-04 19:54:43 +02:00
gitea-actions
f1fd80d9ac chore: bump version to v0.3.30
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 2m43s
2026-04-10 08:18:54 +00:00
Matthieu
24e3e8e989 fix(ui) : fix code block rendering in markdown preview
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Code blocks (triple backticks) had broken styling because prose-code
styles (light background, padding) were also applied to <code> inside
<pre>, conflicting with the dark pre background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 10:18:40 +02:00
gitea-actions
47f2ab9cd4 chore: bump version to v0.3.29
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m11s
Auto Tag Develop / tag (push) Successful in 6s
2026-04-09 14:35:49 +00:00
Matthieu
36729f8f61 feat(task) : add markdown preview for task description
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:35:41 +02:00
gitea-actions
30b090852d chore: bump version to v0.3.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 20s
2026-04-09 12:37:35 +00:00
Matthieu
f0c9568521 feat(infra) : persist logs in prod via named volume
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Add lesstime_logs volume for var/log/ persistence across container
restarts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:34:00 +02:00
70 changed files with 6233 additions and 633 deletions

6
.gitignore vendored
View File

@@ -30,3 +30,9 @@
###> docker local ###
infra/dev/.env.docker.local
###< docker local ###
###> local db dumps ###
*.sql.gz
*.sql.gz:Zone.Identifier
REVIEW.md
###< local db dumps ###

View File

@@ -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
@@ -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.

View File

@@ -21,3 +21,6 @@ mcp:
store: file
directory: '%kernel.project_dir%/var/mcp-sessions'
ttl: 3600
discovery:
scan_dirs: ['src']
exclude_dirs: ['DataFixtures']

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.3.27'
app.version: '0.4.0'

View File

@@ -41,7 +41,7 @@ services:
- "8082:80"
volumes:
- ./:/var/www/html:ro
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/lesstime.conf:ro
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
db:
image: postgres:16-alpine

File diff suppressed because it is too large Load Diff

View 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.

View File

@@ -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>

View 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>

View 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>

View File

@@ -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) -->

View File

@@ -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"

View File

@@ -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[]>([])

View File

@@ -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

View 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>

View File

@@ -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>

View File

@@ -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"
@@ -106,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<{

View File

@@ -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" />

View File

@@ -196,13 +196,10 @@
<!-- 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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[]

View File

@@ -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>

View 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>

View File

@@ -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": {

View File

@@ -37,6 +37,9 @@ export default defineNuxtConfig({
},
},
},
optimizeDeps: {
include: ['markdown-it-task-lists'],
},
},
toast: {
settings: {

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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' },

View File

@@ -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(() => {
@@ -173,8 +184,8 @@ const totalHoursThisWeek = computed(() =>
const myTasks = computed(() =>
tasks.value.filter(t =>
t.assignee?.id === auth.user?.id
|| t.collaborators?.some(c => c.id === auth.user?.id)
t.assignee?.id === effectiveUserId.value
|| t.collaborators?.some(c => c.id === effectiveUserId.value),
)
)

View File

@@ -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'
@@ -60,6 +62,7 @@ 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)
@@ -112,13 +115,11 @@ const sortOptions = computed(() => [
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
])
// Kanban helpers
const sortedStatuses = computed(() =>
[...statuses.value].sort((a, b) => a.position - b.position)
)
// Kanban helpers (grouped by canonical status category)
const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
function tasksByStatus(statusId: number): Task[] {
return tasks.value.filter(t => t.status?.id === statusId)
function tasksByCategory(category: StatusCategory): Task[] {
return tasks.value.filter(t => t.status?.category === category)
}
const backlogTasks = computed(() =>
@@ -205,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
@@ -428,36 +391,29 @@ onMounted(async () => {
</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') }}
@@ -467,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
@@ -483,6 +432,7 @@ onMounted(async () => {
:key="task.id"
:task="task"
show-project-color
show-status-badge
@click="openTaskEdit(task)"
/>
</div>
@@ -507,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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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"

View File

@@ -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)
@@ -326,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(),
@@ -339,7 +346,6 @@ async function loadData() {
])
project.value = p
tasks.value = t
statuses.value = s
efforts.value = e
priorities.value = pr
tags.value = ty

View File

@@ -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()
})

View 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 }
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -8,6 +8,7 @@ export type UserData = {
client?: { id: number; name: string } | null
allowedProjects?: Project[]
avatarUrl?: string | null
apiToken?: string | null
}
export type UserWrite = {

View 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[]
}

View File

@@ -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)
}

View 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 }
}

View File

@@ -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: {

View File

@@ -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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, '\'')
.replace(/\s+/g, ' ')
.trim()
}

View File

@@ -8,6 +8,10 @@ services:
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:

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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;
}
}

View 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]);
}
}

View File

@@ -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');

View File

@@ -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
{

View File

@@ -478,4 +478,26 @@ class Task
;
}
}
#[Assert\Callback]
public function validateStatusBelongsToProjectWorkflow(ExecutionContextInterface $context): void
{
if (null === $this->status || null === $this->project) {
return;
}
$projectWorkflow = $this->project->getWorkflow();
$statusWorkflow = $this->status->getWorkflow();
if (null === $projectWorkflow || null === $statusWorkflow) {
return;
}
if ($projectWorkflow->getId() !== $statusWorkflow->getId()) {
$context->buildViolation('Status does not belong to this project\'s workflow.')
->atPath('status')
->addViolation()
;
}
}
}

View File

@@ -10,9 +10,11 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\StatusCategory;
use App\Repository\TaskStatusRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
@@ -32,25 +34,36 @@ class TaskStatus
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_status:read', 'task:read'])]
#[Groups(['task_status:read', 'task:read', 'workflow:read', 'project:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
private ?string $label = null;
#[ORM\Column(length: 7)]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
private ?string $color = '#222783';
#[ORM\Column]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
private ?int $position = 0;
#[ORM\Column(type: 'boolean')]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
private bool $isFinal = false;
#[ORM\ManyToOne(targetEntity: Workflow::class, inversedBy: 'statuses')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
#[Assert\NotNull]
private ?Workflow $workflow = null;
#[ORM\Column(type: 'string', length: 32, enumType: StatusCategory::class)]
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
#[Assert\NotNull]
private ?StatusCategory $category = null;
public function getId(): ?int
{
return $this->id;
@@ -103,4 +116,28 @@ class TaskStatus
return $this;
}
public function getWorkflow(): ?Workflow
{
return $this->workflow;
}
public function setWorkflow(?Workflow $workflow): static
{
$this->workflow = $workflow;
return $this;
}
public function getCategory(): ?StatusCategory
{
return $this->category;
}
public function setCategory(StatusCategory $category): static
{
$this->category = $category;
return $this;
}
}

View File

@@ -70,6 +70,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(length: 64, unique: true, nullable: true)]
#[Groups(['me:read'])]
private ?string $apiToken = null;
#[ORM\Column(length: 255, nullable: true)]

131
src/Entity/Workflow.php Normal file
View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\WorkflowRepository;
use App\State\WorkflowDeleteProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: WorkflowDeleteProcessor::class),
],
normalizationContext: ['groups' => ['workflow:read']],
denormalizationContext: ['groups' => ['workflow:write']],
order: ['position' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: WorkflowRepository::class)]
#[UniqueEntity(fields: ['name'], message: 'Ce nom de workflow est déjà utilisé.')]
class Workflow
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['workflow:read', 'project:read', 'task_status:read'])]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true)]
#[Groups(['workflow:read', 'workflow:write', 'project:read'])]
#[Assert\NotBlank]
private ?string $name = null;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['workflow:read', 'workflow:write'])]
private bool $isDefault = false;
#[ORM\Column(type: 'integer', options: ['default' => 0])]
#[Groups(['workflow:read', 'workflow:write'])]
private int $position = 0;
/** @var Collection<int, TaskStatus> */
#[ORM\OneToMany(targetEntity: TaskStatus::class, mappedBy: 'workflow', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
#[Groups(['workflow:read', 'project:read'])]
private Collection $statuses;
public function __construct()
{
$this->statuses = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function isDefault(): bool
{
return $this->isDefault;
}
public function setIsDefault(bool $isDefault): static
{
$this->isDefault = $isDefault;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
/** @return Collection<int, TaskStatus> */
public function getStatuses(): Collection
{
return $this->statuses;
}
public function addStatus(TaskStatus $status): static
{
if (!$this->statuses->contains($status)) {
$this->statuses->add($status);
$status->setWorkflow($this);
}
return $this;
}
public function removeStatus(TaskStatus $status): static
{
$this->statuses->removeElement($status);
return $this;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum StatusCategory: string
{
case Todo = 'todo';
case InProgress = 'in_progress';
case Blocked = 'blocked';
case Review = 'review';
case Done = 'done';
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\Workflow;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::onFlush)]
final class UniqueDefaultWorkflowListener
{
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
$candidates = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof Workflow && $entity->isDefault()) {
$candidates[] = $entity;
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($entity instanceof Workflow && $entity->isDefault()) {
$candidates[] = $entity;
}
}
if (0 === count($candidates)) {
return;
}
$metadata = $em->getClassMetadata(Workflow::class);
$repo = $em->getRepository(Workflow::class);
foreach ($repo->findBy(['isDefault' => true]) as $existing) {
if (in_array($existing, $candidates, true)) {
continue;
}
$existing->setIsDefault(false);
$uow->recomputeSingleEntityChangeSet($metadata, $existing);
}
}
}

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs.')]
#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs. The status parameter must reference a status that belongs to the target project\'s workflow — otherwise the call is rejected with a validation error.')]
class CreateTaskTool
{
public function __construct(

View File

@@ -22,7 +22,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs.')]
#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs. The status parameter must reference a status that belongs to the task\'s project workflow — otherwise the call is rejected with a validation error.')]
class UpdateTaskTool
{
public function __construct(

View File

@@ -4,33 +4,49 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta;
use App\Entity\Project;
use App\Repository\TaskStatusRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')]
#[McpTool(
name: 'list-statuses',
description: 'List task statuses. With projectId, returns only the statuses of that project\'s workflow. Without projectId, returns ALL statuses across workflows (use list-workflows to see how they group).',
)]
class ListStatusesTool
{
public function __construct(
private readonly TaskStatusRepository $taskStatusRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
public function __invoke(): string
public function __invoke(?int $projectId = null): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
if (null !== $projectId) {
$project = $this->entityManager->find(Project::class, $projectId);
if (!$project) {
return json_encode(['error' => 'Project not found.']);
}
$statuses = $project->getWorkflow()->getStatuses()->toArray();
} else {
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
}
return json_encode(array_map(fn ($s) => [
'id' => $s->getId(),
'label' => $s->getLabel(),
'color' => $s->getColor(),
'position' => $s->getPosition(),
'isFinal' => $s->getIsFinal(),
'id' => $s->getId(),
'label' => $s->getLabel(),
'color' => $s->getColor(),
'position' => $s->getPosition(),
'isFinal' => $s->getIsFinal(),
'category' => $s->getCategory()->value,
'workflowId' => $s->getWorkflow()?->getId(),
], $statuses));
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Workflow;
use App\Repository\WorkflowRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(
name: 'list-workflows',
description: 'List all workflows (status templates) with their statuses grouped under each workflow. Each project has one workflow that defines its kanban columns.',
)]
class ListWorkflowsTool
{
public function __construct(
private readonly WorkflowRepository $workflowRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$workflows = $this->workflowRepository->findBy([], ['position' => 'ASC']);
return json_encode(array_map(fn ($w) => [
'id' => $w->getId(),
'name' => $w->getName(),
'isDefault' => $w->isDefault(),
'position' => $w->getPosition(),
'statuses' => array_map(fn ($s) => [
'id' => $s->getId(),
'label' => $s->getLabel(),
'color' => $s->getColor(),
'position' => $s->getPosition(),
'isFinal' => $s->getIsFinal(),
'category' => $s->getCategory()->value,
], $w->getStatuses()->toArray()),
], $workflows));
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Workflow;
use ApiPlatform\Metadata\Post;
use App\Entity\Project;
use App\State\SwitchProjectWorkflowProcessor;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Throwable;
#[McpTool(
name: 'switch-project-workflow',
description: 'Switch a project to another workflow. mapping must cover every status currently used by the project\'s tasks: keys are source status IDs (string), values are target status IDs in the new workflow (int) or null to send tasks to backlog. Requires ROLE_ADMIN. Returns { migratedTaskCount }.',
)]
class SwitchProjectWorkflowTool
{
public function __construct(
private readonly SwitchProjectWorkflowProcessor $processor,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
/**
* @param array<string, null|int> $mapping
*/
public function __invoke(int $projectId, int $workflowId, array $mapping): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$project = $this->entityManager->find(Project::class, $projectId);
if (!$project) {
return json_encode(['error' => 'Project not found.']);
}
$fakeRequest = Request::create('', 'POST', [], [], [], [], json_encode([
'workflowId' => $workflowId,
'mapping' => $mapping,
]));
try {
$result = $this->processor->process(
$project,
operation: new Post(name: 'switch_workflow'),
uriVariables: ['id' => $projectId],
context: ['request' => $fakeRequest],
);
} catch (Throwable $e) {
return json_encode(['error' => $e->getMessage()]);
}
return json_encode([
'migratedTaskCount' => $result->migratedTaskCount,
'projectId' => $result->projectId,
'workflowId' => $result->workflowId,
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Workflow;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Workflow>
*/
class WorkflowRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Workflow::class);
}
public function findDefault(): ?Workflow
{
return $this->findOneBy(['isDefault' => true]);
}
}

View File

@@ -14,6 +14,9 @@ use Sabre\VObject\Component\VCalendar;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use const ENT_HTML5;
use const ENT_QUOTES;
final class CalDavService
{
public function __construct(
@@ -199,7 +202,7 @@ final class CalDavService
$project = $task->getProject();
$projectCode = null !== $project ? $project->getCode() : '';
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
$description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
$vcalendar = new VCalendar();
$vcalendar->add('VEVENT', [
@@ -225,7 +228,7 @@ final class CalDavService
$project = $task->getProject();
$projectCode = null !== $project ? $project->getCode() : '';
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
$description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
$vcalendar = new VCalendar();
$vcalendar->add('VTODO', [
@@ -337,6 +340,18 @@ final class CalDavService
return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
}
private function descriptionToPlainText(?string $value): string
{
if (null === $value || '' === $value) {
return '';
}
$stripped = strip_tags($value);
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/[ \t]+/', ' ', $decoded));
}
/** @return array<string, string> */
private function getDayMap(): array
{

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\SwitchWorkflowOutput;
use App\Entity\Project;
use App\Entity\TaskStatus;
use App\Entity\Workflow;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
/**
* Wraps the switch-workflow operation for a project.
* Input: Project (URI variable) + body { workflowId, mapping: { sourceStatusId: targetStatusId|null } }.
*
* @implements ProcessorInterface<Project, SwitchWorkflowOutput>
*/
final readonly class SwitchProjectWorkflowProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SwitchWorkflowOutput
{
/** @var Project $project */
$project = $data;
$request = $context['request'] ?? null;
$body = $request ? json_decode($request->getContent(), true) : [];
$workflowId = $body['workflowId'] ?? null;
$mapping = $body['mapping'] ?? [];
if (!is_int($workflowId) || !is_array($mapping)) {
throw new HttpException(422, 'Body must contain workflowId (int) and mapping (object).');
}
$targetWorkflow = $this->entityManager->find(Workflow::class, $workflowId);
if (!$targetWorkflow instanceof Workflow) {
throw new NotFoundHttpException('Target workflow not found.');
}
// 1) Lister les statuts source effectivement référencés par les tâches du projet
$rows = $this->entityManager->getConnection()->fetchAllAssociative(
'SELECT DISTINCT status_id FROM task WHERE project_id = :pid AND status_id IS NOT NULL',
['pid' => $project->getId()],
);
$referencedSourceIds = array_map(static fn ($r) => (int) $r['status_id'], $rows);
// 2) Vérifier que chaque source a un mapping
$missing = [];
foreach ($referencedSourceIds as $srcId) {
if (!array_key_exists((string) $srcId, $mapping)) {
$missing[] = $srcId;
}
}
if ([] !== $missing) {
throw new HttpException(422, 'Missing mapping for source status IDs: '.implode(', ', $missing));
}
// 3) Valider que chaque target appartient au workflow cible (ou est null)
foreach ($mapping as $srcId => $targetId) {
if (null === $targetId) {
continue;
}
$target = $this->entityManager->find(TaskStatus::class, $targetId);
if (!$target instanceof TaskStatus
|| $target->getWorkflow()?->getId() !== $targetWorkflow->getId()) {
throw new HttpException(422, sprintf(
'Target status %s does not belong to workflow %d.',
var_export($targetId, true),
$targetWorkflow->getId(),
));
}
}
// 4) Transaction unique
$conn = $this->entityManager->getConnection();
$conn->beginTransaction();
try {
$migrated = 0;
foreach ($mapping as $srcId => $targetId) {
$affected = $conn->executeStatement(
'UPDATE task SET status_id = :tid WHERE project_id = :pid AND status_id = :sid',
['tid' => $targetId, 'pid' => $project->getId(), 'sid' => (int) $srcId],
);
$migrated += $affected;
}
$project->setWorkflow($targetWorkflow);
$this->entityManager->flush();
$conn->commit();
} catch (Throwable $e) {
$conn->rollBack();
throw $e;
}
return new SwitchWorkflowOutput(
projectId: $project->getId(),
workflowId: $targetWorkflow->getId(),
migratedTaskCount: $migrated,
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Workflow;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* @implements ProcessorInterface<Workflow, void>
*/
final readonly class WorkflowDeleteProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
/** @var Workflow $workflow */
$workflow = $data;
$count = (int) $this->entityManager->getConnection()->fetchOne(
'SELECT COUNT(*) FROM project WHERE workflow_id = :id',
['id' => $workflow->getId()],
);
if ($count > 0) {
throw new HttpException(409, sprintf(
'Workflow used by %d project(s). Reassign them before deleting.',
$count,
));
}
$this->entityManager->remove($workflow);
$this->entityManager->flush();
}
}