From 6a942def3f2febd884f538533d264936e7ee03bf Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 19 May 2026 17:55:19 +0200 Subject: [PATCH 01/28] docs(workflows) : spec workflows de statuts par projet Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-project-workflows-design.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-project-workflows-design.md diff --git a/docs/superpowers/specs/2026-05-19-project-workflows-design.md b/docs/superpowers/specs/2026-05-19-project-workflows-design.md new file mode 100644 index 0000000..34fffbe --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-project-workflows-design.md @@ -0,0 +1,209 @@ +# Workflows de statuts par projet (Kanban custom) + +**Date** : 2026-05-19 +**Branche** : `feat/project-workflows` +**Statut** : design validé, en attente de plan d'implémentation + +## 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 = ` pour toutes les lignes existantes +- Backfill catégories : + - "À faire" → `todo` + - "En cours" → `in_progress` + - "Bloqué" → `blocked` + - "En attente de validation" → `review` + - "Terminé" → `done` + - Tout autre statut existant non-mappable → `in_progress` (fallback neutre) + log warning +- Passe les 2 colonnes en `NOT NULL` + +**M3 — `add_workflow_to_project`** +- Ajoute `project.workflow_id` nullable +- `UPDATE project SET workflow_id = ` 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: { "": | 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 = WHERE project_id = X AND status_id = ` + - `UPDATE project SET workflow_id = ` +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 ''" + +## 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` (V2 — non bloquant) | Permet de piloter le switch via MCP. Reporté à une itération ultérieure si pas critique. | + +## 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é +- **MCP `switch-project-workflow`** — peut être ajouté en V2 si le besoin se manifeste + +## 9. Risques et limites + +- **Migration M2 (backfill catégories)** : si un déploiement intermédiaire a créé des statuts non prévus, ils tombent sur le fallback `in_progress`. Vérifier l'état de la prod avant migration et ajuster le SQL si besoin. +- **`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` + nouveau `list-workflows` + mise à jour des descriptions +11. Tests : PHPUnit pour le processor + validation cross-entity ; tests fonctionnels du switch + +Le découpage exact (tickets, ordre, dépendances) sera fait dans le plan d'implémentation. From ba86a71e12fd0ffe8ea40e0ad71243c50768a617 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 19 May 2026 17:58:34 +0200 Subject: [PATCH 02/28] docs(workflows) : ajout note de reprise sur autre poste Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-19-project-workflows-design.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/superpowers/specs/2026-05-19-project-workflows-design.md b/docs/superpowers/specs/2026-05-19-project-workflows-design.md index 34fffbe..4f21510 100644 --- a/docs/superpowers/specs/2026-05-19-project-workflows-design.md +++ b/docs/superpowers/specs/2026-05-19-project-workflows-design.md @@ -4,6 +4,22 @@ **Branche** : `feat/project-workflows` **Statut** : design validé, 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. **Avant de lancer le plan, vérifier avec Matthieu** s'il a relu le spec et veut des ajustements sur : +> - Hors scope (§8) — rien d'oublié pour la V1 ? +> - Fallback `in_progress` pour statuts non-mappables (§3, M2) — OK ou échec migration ? +> - Suppression d'AdminStatusTab (§5) — OK de tout fusionner dans l'onglet Workflows ? +> - Ordre des étapes de livraison (§10) — OK ou réordonner ? +> 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. From b8b03048b626fec7347fff539243dd7bae25114e Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:49:26 +0200 Subject: [PATCH 03/28] =?UTF-8?q?feat(workflow)=20:=20ajoute=20l'enum=20St?= =?UTF-8?q?atusCategory=20(5=20cat=C3=A9gories=20canoniques)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Enum/StatusCategory.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/Enum/StatusCategory.php diff --git a/src/Enum/StatusCategory.php b/src/Enum/StatusCategory.php new file mode 100644 index 0000000..c9eaef5 --- /dev/null +++ b/src/Enum/StatusCategory.php @@ -0,0 +1,14 @@ + Date: Tue, 19 May 2026 19:49:51 +0200 Subject: [PATCH 04/28] =?UTF-8?q?feat(workflow)=20:=20ajoute=20l'entit?= =?UTF-8?q?=C3=A9=20Workflow=20et=20son=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Entity/Workflow.php | 131 ++++++++++++++++++++++++++ src/Repository/WorkflowRepository.php | 25 +++++ 2 files changed, 156 insertions(+) create mode 100644 src/Entity/Workflow.php create mode 100644 src/Repository/WorkflowRepository.php diff --git a/src/Entity/Workflow.php b/src/Entity/Workflow.php new file mode 100644 index 0000000..80db9b3 --- /dev/null +++ b/src/Entity/Workflow.php @@ -0,0 +1,131 @@ + ['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 */ + #[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 */ + 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; + } +} diff --git a/src/Repository/WorkflowRepository.php b/src/Repository/WorkflowRepository.php new file mode 100644 index 0000000..fafd288 --- /dev/null +++ b/src/Repository/WorkflowRepository.php @@ -0,0 +1,25 @@ + + */ +class WorkflowRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Workflow::class); + } + + public function findDefault(): ?Workflow + { + return $this->findOneBy(['isDefault' => true]); + } +} From 43e6d1aed2fdec94a2216721ff4891fd0afb7a6c Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:50:13 +0200 Subject: [PATCH 05/28] feat(workflow) : ajoute workflow et category sur TaskStatus --- src/Entity/TaskStatus.php | 47 ++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/Entity/TaskStatus.php b/src/Entity/TaskStatus.php index 0d3d18b..aa69787 100644 --- a/src/Entity/TaskStatus.php +++ b/src/Entity/TaskStatus.php @@ -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; + } } From 8a68e0d397d1f4b8c65b46329d424ca96b524c78 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:50:34 +0200 Subject: [PATCH 06/28] feat(workflow) : ajoute workflow requis sur Project (RESTRICT) --- src/Entity/Project.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 6a5afcd..6191f01 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -69,6 +69,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 +234,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 { From 03f3c85fd899c4b54fc2fe62f93a9c9d0e2a838a Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:51:07 +0200 Subject: [PATCH 07/28] =?UTF-8?q?feat(workflow)=20:=20migration=20M1=20-?= =?UTF-8?q?=20cr=C3=A9ation=20table=20workflow=20+=20seed=20Standard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/Version20260519175041.php | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 migrations/Version20260519175041.php diff --git a/migrations/Version20260519175041.php b/migrations/Version20260519175041.php new file mode 100644 index 0000000..f8ecaa8 --- /dev/null +++ b/migrations/Version20260519175041.php @@ -0,0 +1,36 @@ +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'); + } +} From f6a947ec1575547d51859d40ced1bdc6b8d6f944 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:51:36 +0200 Subject: [PATCH 08/28] =?UTF-8?q?feat(workflow)=20:=20migration=20M2=20-?= =?UTF-8?q?=20rattache=20les=20statuts=20existants=20=C3=A0=20Standard=20+?= =?UTF-8?q?=20category?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/Version20260519175114.php | 74 ++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 migrations/Version20260519175114.php diff --git a/migrations/Version20260519175114.php b/migrations/Version20260519175114.php new file mode 100644 index 0000000..29046bb --- /dev/null +++ b/migrations/Version20260519175114.php @@ -0,0 +1,74 @@ +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'); + } +} From a21914312a5762ab12ce9fca4d876bbab0491c93 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:51:57 +0200 Subject: [PATCH 09/28] feat(workflow) : migration M3 - workflow requis sur Project (RESTRICT) --- migrations/Version20260519175142.php | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 migrations/Version20260519175142.php diff --git a/migrations/Version20260519175142.php b/migrations/Version20260519175142.php new file mode 100644 index 0000000..6f89e01 --- /dev/null +++ b/migrations/Version20260519175142.php @@ -0,0 +1,38 @@ +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'); + } +} From 25f2fc4b16ee3a037c0e7b22f1ae209284cd5ad7 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:52:35 +0200 Subject: [PATCH 10/28] =?UTF-8?q?feat(workflow)=20:=20fixtures=20-=20workf?= =?UTF-8?q?low=20Standard=20+=20statuts=20cat=C3=A9goris=C3=A9s=20+=20proj?= =?UTF-8?q?ets=20attach=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/DataFixtures/AppFixtures.php | 97 ++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index 2236b02..bf8e9a1 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -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'); From a9f87be8e57bd3ec5b8a82ca42d01d85e79a4f33 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:52:47 +0200 Subject: [PATCH 11/28] feat(workflow) : listener garantissant un seul workflow isDefault=true --- .../UniqueDefaultWorkflowListener.php | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/EventListener/UniqueDefaultWorkflowListener.php diff --git a/src/EventListener/UniqueDefaultWorkflowListener.php b/src/EventListener/UniqueDefaultWorkflowListener.php new file mode 100644 index 0000000..15b61c4 --- /dev/null +++ b/src/EventListener/UniqueDefaultWorkflowListener.php @@ -0,0 +1,46 @@ +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); + } + } +} From eec61c089cf8f7952add85f45846c929222cbc96 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:55:04 +0200 Subject: [PATCH 12/28] =?UTF-8?q?feat(workflow)=20:=20migration=20M4=20-?= =?UTF-8?q?=20alignement=20sch=C3=A9ma=20Doctrine=20(indexes=20+=20IDENTIT?= =?UTF-8?q?Y)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/Version20260519175338.php | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 migrations/Version20260519175338.php diff --git a/migrations/Version20260519175338.php b/migrations/Version20260519175338.php new file mode 100644 index 0000000..6a43497 --- /dev/null +++ b/migrations/Version20260519175338.php @@ -0,0 +1,36 @@ +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'); + $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'); + } +} From cf94635121aa023c01a0cd002830ccb31f177da0 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:57:07 +0200 Subject: [PATCH 13/28] feat(workflow) : valide que task.status appartient au workflow du projet --- src/Entity/Task.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Entity/Task.php b/src/Entity/Task.php index 639b929..486371f 100644 --- a/src/Entity/Task.php +++ b/src/Entity/Task.php @@ -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() + ; + } + } } From 80a41db34f0de092370b418b7164001b0799d35b Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:57:34 +0200 Subject: [PATCH 14/28] =?UTF-8?q?feat(workflow)=20:=20prot=C3=A8ge=20la=20?= =?UTF-8?q?suppression=20d'un=20workflow=20li=C3=A9=20=C3=A0=20des=20proje?= =?UTF-8?q?ts=20(409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/State/WorkflowDeleteProcessor.php | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/State/WorkflowDeleteProcessor.php diff --git a/src/State/WorkflowDeleteProcessor.php b/src/State/WorkflowDeleteProcessor.php new file mode 100644 index 0000000..a0efec9 --- /dev/null +++ b/src/State/WorkflowDeleteProcessor.php @@ -0,0 +1,42 @@ + + */ +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(); + } +} From 6a084489eaeb5d5a18751422bc2d98f86626fa16 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 19:59:42 +0200 Subject: [PATCH 15/28] feat(workflow) : endpoint POST /projects/{id}/switch-workflow + processor transactionnel --- src/ApiResource/SwitchWorkflowOutput.php | 26 +++++ src/Entity/Project.php | 16 +++ src/State/SwitchProjectWorkflowProcessor.php | 113 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/ApiResource/SwitchWorkflowOutput.php create mode 100644 src/State/SwitchProjectWorkflowProcessor.php diff --git a/src/ApiResource/SwitchWorkflowOutput.php b/src/ApiResource/SwitchWorkflowOutput.php new file mode 100644 index 0000000..ddc3133 --- /dev/null +++ b/src/ApiResource/SwitchWorkflowOutput.php @@ -0,0 +1,26 @@ +projectId = $projectId; + $this->workflowId = $workflowId; + $this->migratedTaskCount = $migratedTaskCount; + } +} diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 6191f01..04ce728 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -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']], diff --git a/src/State/SwitchProjectWorkflowProcessor.php b/src/State/SwitchProjectWorkflowProcessor.php new file mode 100644 index 0000000..c5822ae --- /dev/null +++ b/src/State/SwitchProjectWorkflowProcessor.php @@ -0,0 +1,113 @@ + + */ +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, + ); + } +} From 18bc96082f7cc07960b1fcfab785db1c1a3cbc38 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 20:00:55 +0200 Subject: [PATCH 16/28] =?UTF-8?q?feat(workflow)=20:=20DTOs=20front=20Workf?= =?UTF-8?q?low=20+=20category=20sur=20TaskStatus=20+=20workflow=20embarqu?= =?UTF-8?q?=C3=A9=20sur=20Project=20+=20service=20+=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 31 ++++++++++++++++ frontend/services/dto/project.ts | 3 ++ frontend/services/dto/task-status.ts | 6 +++ frontend/services/dto/workflow.ts | 27 ++++++++++++++ frontend/services/workflows.ts | 55 ++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+) create mode 100644 frontend/services/dto/workflow.ts create mode 100644 frontend/services/workflows.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index b30adc6..df24394 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -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.", diff --git a/frontend/services/dto/project.ts b/frontend/services/dto/project.ts index 10cbb3c..8e36770 100644 --- a/frontend/services/dto/project.ts +++ b/frontend/services/dto/project.ts @@ -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 diff --git a/frontend/services/dto/task-status.ts b/frontend/services/dto/task-status.ts index 0b2de86..610d3b5 100644 --- a/frontend/services/dto/task-status.ts +++ b/frontend/services/dto/task-status.ts @@ -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 } diff --git a/frontend/services/dto/workflow.ts b/frontend/services/dto/workflow.ts new file mode 100644 index 0000000..783dd1f --- /dev/null +++ b/frontend/services/dto/workflow.ts @@ -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 = { + 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[] +} diff --git a/frontend/services/workflows.ts b/frontend/services/workflows.ts new file mode 100644 index 0000000..1fd86cc --- /dev/null +++ b/frontend/services/workflows.ts @@ -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 +} + +type SwitchResult = { + projectId: number + workflowId: number + migratedTaskCount: number +} + +export function useWorkflowService() { + const api = useApi() + + async function getAll(): Promise { + const data = await api.get>('/workflows') + return extractHydraMembers(data) + } + + async function getOne(id: number): Promise { + return api.get(`/workflows/${id}`) + } + + async function create(payload: WorkflowWrite): Promise { + return api.post('/workflows', payload as Record, { + toastSuccessKey: 'workflows.created', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/workflows/${id}`, payload as Record, { + toastSuccessKey: 'workflows.updated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/workflows/${id}`, {}, { + toastSuccessKey: 'workflows.deleted', + }) + } + + async function switchOnProject(projectId: number, payload: SwitchPayload): Promise { + return api.post( + `/projects/${projectId}/switch-workflow`, + payload as unknown as Record, + { toastSuccessKey: 'workflows.switched' }, + ) + } + + return { getAll, getOne, create, update, remove, switchOnProject } +} From 8e4ddf00a80b62a4db9c337c0a02f9a5687310e1 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 20:04:53 +0200 Subject: [PATCH 17/28] =?UTF-8?q?feat(workflow)=20:=20admin=20UI=20-=20Wor?= =?UTF-8?q?kflowDrawer=20+=20AdminWorkflowTab=20+=20remplacement=20onglet?= =?UTF-8?q?=20Statuts,=20suppression=20composants=20obsol=C3=A8tes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/AdminStatusTab.vue | 140 ---------- .../components/admin/AdminWorkflowTab.vue | 100 +++++++ frontend/components/admin/WorkflowDrawer.vue | 259 ++++++++++++++++++ frontend/components/task/TaskStatusDrawer.vue | 122 --------- .../ui/ConfirmDeleteStatusModal.vue | 93 ------- frontend/pages/admin.vue | 4 +- 6 files changed, 361 insertions(+), 357 deletions(-) delete mode 100644 frontend/components/admin/AdminStatusTab.vue create mode 100644 frontend/components/admin/AdminWorkflowTab.vue create mode 100644 frontend/components/admin/WorkflowDrawer.vue delete mode 100644 frontend/components/task/TaskStatusDrawer.vue delete mode 100644 frontend/components/ui/ConfirmDeleteStatusModal.vue diff --git a/frontend/components/admin/AdminStatusTab.vue b/frontend/components/admin/AdminStatusTab.vue deleted file mode 100644 index d21a2e4..0000000 --- a/frontend/components/admin/AdminStatusTab.vue +++ /dev/null @@ -1,140 +0,0 @@ - - - diff --git a/frontend/components/admin/AdminWorkflowTab.vue b/frontend/components/admin/AdminWorkflowTab.vue new file mode 100644 index 0000000..0c6fbdd --- /dev/null +++ b/frontend/components/admin/AdminWorkflowTab.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/components/admin/WorkflowDrawer.vue b/frontend/components/admin/WorkflowDrawer.vue new file mode 100644 index 0000000..a545fe1 --- /dev/null +++ b/frontend/components/admin/WorkflowDrawer.vue @@ -0,0 +1,259 @@ + + + diff --git a/frontend/components/task/TaskStatusDrawer.vue b/frontend/components/task/TaskStatusDrawer.vue deleted file mode 100644 index bff9b06..0000000 --- a/frontend/components/task/TaskStatusDrawer.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - diff --git a/frontend/components/ui/ConfirmDeleteStatusModal.vue b/frontend/components/ui/ConfirmDeleteStatusModal.vue deleted file mode 100644 index 852b703..0000000 --- a/frontend/components/ui/ConfirmDeleteStatusModal.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - - - diff --git a/frontend/pages/admin.vue b/frontend/pages/admin.vue index 12b3684..0743847 100644 --- a/frontend/pages/admin.vue +++ b/frontend/pages/admin.vue @@ -22,7 +22,7 @@
- + @@ -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' }, From 5d42009348caf49dc59fbb4e2a957bd87aa6d7c3 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 20:06:44 +0200 Subject: [PATCH 18/28] =?UTF-8?q?feat(workflow)=20:=20kanban=20projet=20et?= =?UTF-8?q?=20archives=20bas=C3=A9s=20sur=20workflow.statuses=20du=20proje?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/pages/projects/[id]/archives.vue | 11 +++++------ frontend/pages/projects/[id]/index.vue | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/frontend/pages/projects/[id]/archives.vue b/frontend/pages/projects/[id]/archives.vue index 703bc82..e41df4a 100644 --- a/frontend/pages/projects/[id]/archives.vue +++ b/frontend/pages/projects/[id]/archives.vue @@ -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(null) const archivedTasks = ref([]) -const statuses = ref([]) const efforts = ref([]) + +const statuses = computed(() => + [...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position), +) const priorities = ref([]) const tags = ref([]) const groups = ref([]) @@ -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 diff --git a/frontend/pages/projects/[id]/index.vue b/frontend/pages/projects/[id]/index.vue index 45cd70d..10490ab 100644 --- a/frontend/pages/projects/[id]/index.vue +++ b/frontend/pages/projects/[id]/index.vue @@ -218,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' @@ -234,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() @@ -243,7 +241,6 @@ const userService = useUserService() const project = ref(null) const tasks = ref([]) -const statuses = ref([]) const efforts = ref([]) const priorities = ref([]) const tags = ref([]) @@ -252,6 +249,10 @@ const users = ref([]) const clients = ref([]) const isLoading = ref(true) +const statuses = computed(() => + [...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position), +) + const selectedGroupId = ref(null) const selectedTagId = ref(null) const selectedAssigneeId = ref(null) @@ -333,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(), @@ -346,7 +346,6 @@ async function loadData() { ]) project.value = p tasks.value = t - statuses.value = s efforts.value = e priorities.value = pr tags.value = ty From e6d765f7bb008bd2329cdb35d8655af6dc39bbb4 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 20:09:17 +0200 Subject: [PATCH 19/28] =?UTF-8?q?feat(workflow)=20:=20my-tasks=20-=20kanba?= =?UTF-8?q?n=20group=C3=A9=20par=20cat=C3=A9gorie=20avec=20badge=20statut,?= =?UTF-8?q?=20suppression=20drag-to-status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/task/TaskCard.vue | 9 +++ frontend/pages/my-tasks.vue | 87 ++++++--------------------- 2 files changed, 27 insertions(+), 69 deletions(-) diff --git a/frontend/components/task/TaskCard.vue b/frontend/components/task/TaskCard.vue index 0a5f57b..c8a92ef 100644 --- a/frontend/components/task/TaskCard.vue +++ b/frontend/components/task/TaskCard.vue @@ -40,6 +40,13 @@
+ + {{ task.status.label }} + (), { showProjectColor: false, + showStatusBadge: false, }) const emit = defineEmits<{ diff --git a/frontend/pages/my-tasks.vue b/frontend/pages/my-tasks.vue index ce36c08..d0bd9bb 100644 --- a/frontend/pages/my-tasks.vue +++ b/frontend/pages/my-tasks.vue @@ -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' @@ -112,13 +114,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 +205,6 @@ watch(selectedProjectId, () => { selectedGroupId.value = null }, { flush: 'sync' }) -// Drag & drop -const dragOverStatusId = ref(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 +390,29 @@ onMounted(async () => {
- +
-
- {{ status.label }} ({{ tasksByStatus(status.id).length }}) +
+ {{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})

{{ $t('myTasks.noTasks') }} @@ -467,15 +422,8 @@ onMounted(async () => {

- -
+ +

{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})

{ :key="task.id" :task="task" show-project-color + show-status-badge @click="openTaskEdit(task)" />
From 52b78d6bbc7d77cb234f48a258eac6d544d35b0f Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 20:10:47 +0200 Subject: [PATCH 20/28] feat(workflow) : ProjectWorkflowSwitchModal + section workflow et bouton switch dans ProjectDrawer --- frontend/components/project/ProjectDrawer.vue | 34 +++ .../project/ProjectWorkflowSwitchModal.vue | 209 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 frontend/components/project/ProjectWorkflowSwitchModal.vue diff --git a/frontend/components/project/ProjectDrawer.vue b/frontend/components/project/ProjectDrawer.vue index f8ba6fb..db3d837 100644 --- a/frontend/components/project/ProjectDrawer.vue +++ b/frontend/components/project/ProjectDrawer.vue @@ -87,10 +87,35 @@
+
+
+
+

{{ $t('workflows.title') }}

+

{{ props.project.workflow?.name }}

+
+ +
+
+ + + @@ -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([]) diff --git a/frontend/components/project/ProjectWorkflowSwitchModal.vue b/frontend/components/project/ProjectWorkflowSwitchModal.vue new file mode 100644 index 0000000..0fee759 --- /dev/null +++ b/frontend/components/project/ProjectWorkflowSwitchModal.vue @@ -0,0 +1,209 @@ + + + + + From 6a37349cf7a4f6e1f955e7597509c1e9bacefce0 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 20:12:01 +0200 Subject: [PATCH 21/28] =?UTF-8?q?feat(workflow)=20:=20bulk=20status=20d?= =?UTF-8?q?=C3=A9sactiv=C3=A9=20sur=20s=C3=A9lection=20multi-projets,=20sc?= =?UTF-8?q?oped=20au=20workflow=20du=20projet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/task/TaskBulkActions.vue | 54 ++++++++++++++++---- frontend/pages/my-tasks.vue | 3 ++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/frontend/components/task/TaskBulkActions.vue b/frontend/components/task/TaskBulkActions.vue index 5c9194a..617aa19 100644 --- a/frontend/components/task/TaskBulkActions.vue +++ b/frontend/components/task/TaskBulkActions.vue @@ -14,8 +14,9 @@
- + + + Status — + diff --git a/frontend/pages/my-tasks.vue b/frontend/pages/my-tasks.vue index d0bd9bb..cece4ab 100644 --- a/frontend/pages/my-tasks.vue +++ b/frontend/pages/my-tasks.vue @@ -62,6 +62,7 @@ const viewMode = ref<'kanban' | 'list'>('kanban') // Bulk selection const selectedTaskIds = reactive(new Set()) +const selectedTasksArray = computed(() => tasks.value.filter(t => selectedTaskIds.has(t.id))) // Modal const taskModalOpen = ref(false) @@ -456,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" From 9f179e400de197dc6efc272386650460d102eaac Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 20:13:53 +0200 Subject: [PATCH 22/28] feat(workflow) : MCP - list-statuses projectId + list-workflows + switch-project-workflow + maj descriptions create/update-task --- src/Mcp/Tool/Task/CreateTaskTool.php | 2 +- src/Mcp/Tool/Task/UpdateTaskTool.php | 2 +- src/Mcp/Tool/TaskMeta/ListStatusesTool.php | 32 ++++++--- src/Mcp/Tool/Workflow/ListWorkflowsTool.php | 46 +++++++++++++ .../Workflow/SwitchProjectWorkflowTool.php | 65 +++++++++++++++++++ 5 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 src/Mcp/Tool/Workflow/ListWorkflowsTool.php create mode 100644 src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php diff --git a/src/Mcp/Tool/Task/CreateTaskTool.php b/src/Mcp/Tool/Task/CreateTaskTool.php index 922e8aa..7919819 100644 --- a/src/Mcp/Tool/Task/CreateTaskTool.php +++ b/src/Mcp/Tool/Task/CreateTaskTool.php @@ -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( diff --git a/src/Mcp/Tool/Task/UpdateTaskTool.php b/src/Mcp/Tool/Task/UpdateTaskTool.php index f078cd6..82c0c6b 100644 --- a/src/Mcp/Tool/Task/UpdateTaskTool.php +++ b/src/Mcp/Tool/Task/UpdateTaskTool.php @@ -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( diff --git a/src/Mcp/Tool/TaskMeta/ListStatusesTool.php b/src/Mcp/Tool/TaskMeta/ListStatusesTool.php index b516933..db6c8e3 100644 --- a/src/Mcp/Tool/TaskMeta/ListStatusesTool.php +++ b/src/Mcp/Tool/TaskMeta/ListStatusesTool.php @@ -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)); } } diff --git a/src/Mcp/Tool/Workflow/ListWorkflowsTool.php b/src/Mcp/Tool/Workflow/ListWorkflowsTool.php new file mode 100644 index 0000000..03701d2 --- /dev/null +++ b/src/Mcp/Tool/Workflow/ListWorkflowsTool.php @@ -0,0 +1,46 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php b/src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php new file mode 100644 index 0000000..f0448f7 --- /dev/null +++ b/src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php @@ -0,0 +1,65 @@ + $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, + ]); + } +} From 1fd2c05db38cd87e406c84609169c7212879c50c Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 20:14:30 +0200 Subject: [PATCH 23/28] chore : bump version to v0.4.0 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index 26d9560..9409b68 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.3.34' + app.version: '0.4.0' From f86698e7cddd1131dbfc2ae7747bcbb8a6d2841c Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 20:46:01 +0200 Subject: [PATCH 24/28] =?UTF-8?q?docs(workflows)=20:=20plan=20d'impl=C3=A9?= =?UTF-8?q?mentation=20+=20validations=20Matthieu=20sur=20le=20spec=20+=20?= =?UTF-8?q?gitignore=20dumps=20locaux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + .../plans/2026-05-19-project-workflows.md | 3036 +++++++++++++++++ .../2026-05-19-project-workflows-design.md | 25 +- 3 files changed, 3054 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-19-project-workflows.md diff --git a/.gitignore b/.gitignore index 9e182e1..8ff156c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 ### diff --git a/docs/superpowers/plans/2026-05-19-project-workflows.md b/docs/superpowers/plans/2026-05-19-project-workflows.md new file mode 100644 index 0000000..2b5a494 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-project-workflows.md @@ -0,0 +1,3036 @@ +# Workflows de statuts par projet — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Permettre à chaque projet d'avoir son propre kanban (workflow réutilisable défini en admin), tout en gardant les vues transverses (`my-tasks`, archives, time tracking) cohérentes. + +**Architecture:** Nouvelle entité `Workflow` (templates), chaque `TaskStatus` appartient à un workflow et porte une `category` enum (5 valeurs canoniques : todo, in_progress, blocked, review, done). `Project.workflow_id` est requis (FK RESTRICT). Vue `my-tasks` regroupe par catégorie ; vue `projects/[id]` utilise les statuts du workflow du projet. Endpoint dédié `POST /api/projects/{id}/switch-workflow` avec mapping source→cible exécuté en transaction. UI admin fusionne workflows et statuts (un seul onglet). MCP gagne `list-workflows` et `switch-project-workflow`. + +**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3 Composition API, TypeScript, Tailwind, `@malio/layer-ui`, MCP SDK PHP. + +**Spec:** `docs/superpowers/specs/2026-05-19-project-workflows-design.md` + +--- + +## Fichiers créés / modifiés + +### Backend +| Fichier | Action | Responsabilité | +|---|---|---| +| `src/Enum/StatusCategory.php` | Create | Enum PHP backed des 5 catégories | +| `src/Entity/Workflow.php` | Create | Entité Workflow (ApiResource ROLE_ADMIN pour Post/Patch/Delete) | +| `src/Repository/WorkflowRepository.php` | Create | Repository standard + `findDefault()` | +| `src/Entity/TaskStatus.php` | Modify | Ajout `workflow` (ManyToOne), `category` (enum), groupes sérialisation | +| `src/Entity/Project.php` | Modify | Ajout `workflow` (ManyToOne, requise), embarqué en sérialisation | +| `src/Entity/Task.php` | Modify | Ajout `Assert\Callback` validant `status.workflow === project.workflow` | +| `src/EventListener/UniqueDefaultWorkflowListener.php` | Create | Listener Doctrine garantissant un seul `isDefault=true` | +| `src/ApiResource/SwitchWorkflowOutput.php` | Create | DTO de réponse de l'endpoint switch (project IRI + migratedTaskCount) | +| `src/State/SwitchProjectWorkflowProcessor.php` | Create | Processor de l'endpoint switch (transaction + mapping) | +| `src/State/WorkflowDeleteProcessor.php` | Create | Processor Delete renvoyant 409 si projets liés | +| `migrations/Version_create_workflow.php` | Create | Migration M1 | +| `migrations/Version_add_workflow_to_task_status.php` | Create | Migration M2 (backfill + NOT NULL + échec si label inconnu) | +| `migrations/Version_add_workflow_to_project.php` | Create | Migration M3 | +| `src/DataFixtures/AppFixtures.php` | Modify | Création du workflow Standard + assignation aux projets fixtures | +| `src/Mcp/Tool/TaskMeta/ListStatusesTool.php` | Modify | Param optionnel `projectId` | +| `src/Mcp/Tool/Workflow/ListWorkflowsTool.php` | Create | Nouveau tool MCP | +| `src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php` | Create | Nouveau tool MCP (ROLE_ADMIN) | +| `tests/Functional/SwitchProjectWorkflowTest.php` | Create | Tests fonctionnels endpoint switch | +| `tests/Functional/TaskWorkflowValidationTest.php` | Create | Tests cross-entity validation | +| `tests/Functional/WorkflowDeleteProtectionTest.php` | Create | Tests 409 sur Delete workflow lié | + +### Frontend +| Fichier | Action | Responsabilité | +|---|---|---| +| `frontend/services/dto/workflow.ts` | Create | Types TS Workflow + WorkflowWrite + StatusCategory | +| `frontend/services/workflows.ts` | Create | Service API CRUD + switch | +| `frontend/services/dto/task-status.ts` | Modify | Ajout `category`, `workflow` | +| `frontend/services/dto/project.ts` | Modify | Ajout `workflow` (embedded) | +| `frontend/components/admin/AdminWorkflowTab.vue` | Create | Onglet admin liste workflows + édition inline statuts | +| `frontend/components/admin/WorkflowDrawer.vue` | Create | Drawer création/édition workflow (nom + statuts) | +| `frontend/components/admin/AdminStatusTab.vue` | **Delete** | Fusionné dans AdminWorkflowTab | +| `frontend/pages/admin.vue` | Modify | Remplace l'onglet Statuts par l'onglet Workflows | +| `frontend/pages/projects/[id]/index.vue` | Modify | Kanban basé sur `project.workflow.statuses` | +| `frontend/pages/projects/[id]/archives.vue` | Modify | Filtre statut limité au workflow du projet | +| `frontend/pages/my-tasks.vue` | Modify | Kanban groupé par catégorie (5 colonnes) + badge statut sur cards | +| `frontend/components/task/TaskCard.vue` | Modify | Affichage du badge statut (label+couleur) en mode `show-status-badge` | +| `frontend/components/project/ProjectWorkflowSwitchModal.vue` | Create | Modal de migration source→cible | +| `frontend/components/project/ProjectDrawer.vue` | Modify | Affiche workflow + bouton "Changer de workflow" | +| `frontend/components/task/TaskBulkActions.vue` | Modify | Désactive le bulk-status si multi-projets | +| `frontend/i18n/locales/fr.json` | Modify | Traductions workflows | +| `frontend/i18n/locales/en.json` | Modify | Traductions workflows | + +--- + +## Phase 1 — Backend : enum + entité + migrations + +## Task 1: StatusCategory enum + +**Files:** +- Create: `src/Enum/StatusCategory.php` + +- [ ] **Step 1: Écrire l'enum** + +```php +findOneBy(['isDefault' => true]); + } +} +``` + +- [ ] **Step 2: Entité Workflow** + +```php + ['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 */ + #[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 */ + 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; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/Workflow.php src/Repository/WorkflowRepository.php +git commit -m "feat(workflow) : ajoute l'entité Workflow et son repository" +``` + +> Note : `WorkflowDeleteProcessor` est référencé ici mais sera créé en Task 14. Le code compile mais l'opération Delete restera non fonctionnelle jusque-là — c'est OK, on n'écrira pas dans la table avant Task 9 (migrations). + +--- + +## Task 3: Modifications TaskStatus (workflow + category) + +**Files:** +- Modify: `src/Entity/TaskStatus.php` + +- [ ] **Step 1: Ajouter les imports et propriétés** + +Remplacer le contenu de `src/Entity/TaskStatus.php` par : + +```php + ['task_status:read']], + denormalizationContext: ['groups' => ['task_status:write']], + order: ['position' => 'ASC'], +)] +#[ORM\Entity(repositoryClass: TaskStatusRepository::class)] +class TaskStatus +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[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', 'workflow:read', 'project:read'])] + private ?string $label = null; + + #[ORM\Column(length: 7)] + #[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', 'workflow:read', 'project:read'])] + private ?int $position = 0; + + #[ORM\Column(type: 'boolean')] + #[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; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getColor(): ?string + { + return $this->color; + } + + public function setColor(string $color): static + { + $this->color = $color; + + return $this; + } + + public function getPosition(): ?int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } + + public function getIsFinal(): bool + { + return $this->isFinal; + } + + public function setIsFinal(bool $isFinal): static + { + $this->isFinal = $isFinal; + + 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; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/TaskStatus.php +git commit -m "feat(workflow) : ajoute workflow et category sur TaskStatus" +``` + +--- + +## Task 4: Modifications Project (workflow embarqué) + +**Files:** +- Modify: `src/Entity/Project.php` + +- [ ] **Step 1: Ajouter la relation Workflow** + +Dans `src/Entity/Project.php`, ajouter après l'import `Doctrine\ORM\Mapping as ORM;` : + +```php +use Symfony\Component\Validator\Constraints as Assert; +``` + +(s'il n'est pas déjà importé) + +Puis ajouter la propriété après `private ?Client $client = null;` (vers ligne 70) : + +```php + #[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; +``` + +Puis ajouter les accesseurs à la fin de la classe (avant `getTaskCount`) : + +```php + public function getWorkflow(): ?Workflow + { + return $this->workflow; + } + + public function setWorkflow(Workflow $workflow): static + { + $this->workflow = $workflow; + + return $this; + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/Project.php +git commit -m "feat(workflow) : ajoute workflow requis sur Project (RESTRICT)" +``` + +--- + +## Task 5: Migration M1 — table workflow + insert Standard + +**Files:** +- Create: `migrations/Version_create_workflow.php` + +- [ ] **Step 1: Générer le squelette** + +```bash +docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate +``` + +Renommer le fichier généré en `Version_create_workflow.php` pour clarté (Doctrine n'impose pas le nom). + +- [ ] **Step 2: Remplir le up/down** + +```php + 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'); + } +} +``` + +- [ ] **Step 3: Vérifier syntaxe** + +```bash +docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:list +``` + +Expected: la nouvelle migration apparaît "Not migrated". + +- [ ] **Step 4: Commit** + +```bash +git add migrations/Version_create_workflow.php +git commit -m "feat(workflow) : migration M1 - création table workflow + seed Standard" +``` + +--- + +## Task 6: Migration M2 — task_status.workflow_id + category + backfill strict + +**Files:** +- Create: `migrations/Version_add_workflow_to_task_status.php` + +- [ ] **Step 1: Générer + remplir** + +```bash +docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate +``` + +```php + 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'); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add migrations/Version_add_workflow_to_task_status.php +git commit -m "feat(workflow) : migration M2 - rattache les statuts existants à Standard + category" +``` + +--- + +## Task 7: Migration M3 — project.workflow_id (RESTRICT) + +**Files:** +- Create: `migrations/Version_add_workflow_to_project.php` + +- [ ] **Step 1: Générer + remplir** + +```bash +docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate +``` + +```php + 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'); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add migrations/Version_add_workflow_to_project.php +git commit -m "feat(workflow) : migration M3 - workflow requis sur Project (RESTRICT)" +``` + +--- + +## Task 8: Mise à jour des fixtures (workflow Standard + statuts attachés) + +**Files:** +- Modify: `src/DataFixtures/AppFixtures.php` + +- [ ] **Step 1: Ajouter l'import** + +En haut du fichier, ajouter : + +```php +use App\Entity\Workflow; +use App\Enum\StatusCategory; +``` + +- [ ] **Step 2: Créer le workflow Standard avant les statuts** + +Localiser le bloc `// Task Statuses (global)` (vers ligne 122). Remplacer le bloc par : + +```php + // Workflow par défaut + $standardWorkflow = new Workflow(); + $standardWorkflow->setName('Standard'); + $standardWorkflow->setIsDefault(true); + $standardWorkflow->setPosition(0); + $manager->persist($standardWorkflow); + + // Task Statuses (rattachés au workflow Standard) + $defaultStatuses = [ + ['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, $category, $isFinal]) { + $status = new TaskStatus(); + $status->setLabel($label); + $status->setColor($color); + $status->setPosition($position); + $status->setCategory($category); + $status->setIsFinal($isFinal); + $standardWorkflow->addStatus($status); + $manager->persist($status); + $statusObjects[$label] = $status; + } + + $statusTodo = $statusObjects['A faire']; + $statusInProgress = $statusObjects['En cours']; + $statusBlocked = $statusObjects['Bloqué']; + $statusReview = $statusObjects['En attente de validation']; + $statusDone = $statusObjects['Terminé']; +``` + +- [ ] **Step 3: Assigner le workflow Standard à tous les projets fixtures** + +Localiser chaque création de `Project` (recherche `new Project()`). Pour chacun, ajouter juste après `setColor` : + +```php + $project->setWorkflow($standardWorkflow); +``` + +(remplacer `` par le nom de variable du projet : `$projectClient`, `$projectInterne`, etc.) + +- [ ] **Step 4: Commit** + +```bash +git add src/DataFixtures/AppFixtures.php +git commit -m "feat(workflow) : fixtures - workflow Standard + statuts catégorisés + projets attachés" +``` + +--- + +## Task 9: Listener unique-default-workflow + +**Files:** +- Create: `src/EventListener/UniqueDefaultWorkflowListener.php` + +- [ ] **Step 1: Écrire le listener** + +```php +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 (count($candidates) === 0) { + return; + } + + // Démarquer tous les autres workflows + $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); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/EventListener/UniqueDefaultWorkflowListener.php +git commit -m "feat(workflow) : listener garantissant un seul workflow isDefault=true" +``` + +--- + +## Task 10: Appliquer migrations + fixtures + smoke test + +**Files:** (aucun fichier source — vérification runtime) + +- [ ] **Step 1: Lancer les migrations** + +```bash +make migration-migrate +``` + +Expected: les 3 migrations passent vert. + +- [ ] **Step 2: Vérifier le schéma** + +```bash +docker exec -t php-lesstime-fpm php bin/console doctrine:schema:validate +``` + +Expected: "The mapping files are correct." et "The database schema is in sync with the mapping files." + +- [ ] **Step 3: Recharger les fixtures** + +```bash +make fixtures +``` + +Expected: pas d'erreur. + +- [ ] **Step 4: Vérifier le contenu via psql** + +```bash +docker exec -t php-lesstime-fpm bash -c "PGPASSWORD=malio psql -h host.docker.internal -p 5432 -U malio -d lesstime -c 'SELECT id, name, is_default FROM workflow'" +docker exec -t php-lesstime-fpm bash -c "PGPASSWORD=malio psql -h host.docker.internal -p 5432 -U malio -d lesstime -c 'SELECT id, label, category, workflow_id FROM task_status ORDER BY position'" +docker exec -t php-lesstime-fpm bash -c "PGPASSWORD=malio psql -h host.docker.internal -p 5432 -U malio -d lesstime -c 'SELECT id, code, workflow_id FROM project'" +``` + +Expected: workflow Standard existe, 5 statuts avec category remplie, tous les projets ont `workflow_id` non-null. + +- [ ] **Step 5: Smoke test API** + +```bash +docker exec -t php-lesstime-fpm curl -sS http://localhost/api/workflows -H "Cookie: BEARER=$(echo)" | head -50 +``` + +(Test alternatif via UI : se logger en admin et appeler `/api/workflows` depuis devtools) + +--- + +## Phase 2 — Sérialisation, validation cross-entity, Delete protection + +## Task 11: Validation cross-entity sur Task (status.workflow == project.workflow) + +**Files:** +- Modify: `src/Entity/Task.php` + +- [ ] **Step 1: Ajouter la validation** + +Dans `src/Entity/Task.php`, ajouter à la fin de la classe (après `validateCollaborators`) : + +```php + #[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; // états transitoires + } + + if ($projectWorkflow->getId() !== $statusWorkflow->getId()) { + $context->buildViolation('Status does not belong to this project\'s workflow.') + ->atPath('status') + ->addViolation() + ; + } + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/Task.php +git commit -m "feat(workflow) : valide que task.status appartient au workflow du projet" +``` + +--- + +## Task 12: WorkflowDeleteProcessor (409 si projets liés) + +**Files:** +- Create: `src/State/WorkflowDeleteProcessor.php` + +- [ ] **Step 1: Test fonctionnel d'abord (TDD)** + +Créer `tests/Functional/WorkflowDeleteProtectionTest.php` : + +```php +loginUser($this->getAdmin()); + + // Le workflow Standard est lié à tous les projets fixtures + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get(EntityManagerInterface::class); + $standard = $em->getRepository(Workflow::class)->findOneBy(['name' => 'Standard']); + self::assertNotNull($standard); + + $client->request('DELETE', '/api/workflows/' . $standard->getId()); + + self::assertResponseStatusCodeSame(409); + self::assertJsonContains(['title' => 'Workflow used by linked projects']); + } + + private function getAdmin(): \App\Entity\User + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(\App\Entity\User::class)->findOneBy(['username' => 'admin']); + self::assertNotNull($user); + + return $user; + } +} +``` + +- [ ] **Step 2: Lancer le test (FAIL attendu)** + +```bash +make test -- --filter WorkflowDeleteProtectionTest +``` + +Expected: échec (le processor n'existe pas encore). + +- [ ] **Step 3: Implémenter le processor** + +```php + + */ +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(); + } +} +``` + +- [ ] **Step 4: Lancer le test (PASS attendu)** + +```bash +make test -- --filter WorkflowDeleteProtectionTest +``` + +Expected: green. + +- [ ] **Step 5: Commit** + +```bash +git add src/State/WorkflowDeleteProcessor.php tests/Functional/WorkflowDeleteProtectionTest.php +git commit -m "feat(workflow) : protège la suppression d'un workflow lié à des projets (409)" +``` + +--- + +## Task 13: Test fonctionnel — validation cross-entity Task + +**Files:** +- Create: `tests/Functional/TaskWorkflowValidationTest.php` + +- [ ] **Step 1: Écrire le test** + +```php +get(EntityManagerInterface::class); + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + + // Workflow B : un workflow alternatif avec ses propres statuts + $workflowB = (new Workflow())->setName('AltKanban')->setPosition(1); + $statusB = (new TaskStatus()) + ->setLabel('Backlog') + ->setColor('#000000') + ->setPosition(0) + ->setCategory(StatusCategory::Todo); + $workflowB->addStatus($statusB); + $em->persist($workflowB); + $em->flush(); + + // Project sur workflow Standard (fixtures) + $project = $em->getRepository(Project::class)->findOneBy([]); + self::assertNotNull($project); + self::assertNotSame($project->getWorkflow()->getId(), $workflowB->getId()); + + $response = $client->request('POST', '/api/tasks', [ + 'json' => [ + 'title' => 'Test mismatch', + 'project' => '/api/projects/' . $project->getId(), + 'status' => '/api/task_statuses/' . $statusB->getId(), + ], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertJsonContains(['violations' => [['message' => 'Status does not belong to this project\'s workflow.']]]); + } +} +``` + +- [ ] **Step 2: Lancer + vérifier green** + +```bash +make test -- --filter TaskWorkflowValidationTest +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/Functional/TaskWorkflowValidationTest.php +git commit -m "test(workflow) : valide le rejet d'un status hors workflow projet" +``` + +--- + +## Phase 3 — Endpoint switch-workflow + +## Task 14: SwitchProjectWorkflowProcessor + +**Files:** +- Create: `src/State/SwitchProjectWorkflowProcessor.php` + +- [ ] **Step 1: Test fonctionnel (TDD)** + +Créer `tests/Functional/SwitchProjectWorkflowTest.php` : + +```php +get(EntityManagerInterface::class); + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + + // Workflow B + $workflowB = (new Workflow())->setName('DevKanban')->setPosition(1); + $statusBDev = (new TaskStatus())->setLabel('In Dev')->setColor('#0F0')->setPosition(0)->setCategory(StatusCategory::InProgress); + $workflowB->addStatus($statusBDev); + $em->persist($workflowB); + + // Project Standard avec une tâche "En cours" + $project = $em->getRepository(Project::class)->findOneBy([]); + $standardEnCours = $em->getRepository(TaskStatus::class)->findOneBy([ + 'label' => 'En cours', + 'workflow' => $project->getWorkflow(), + ]); + self::assertNotNull($standardEnCours); + + $task = (new Task()) + ->setNumber(9999) + ->setTitle('Test switch') + ->setProject($project) + ->setStatus($standardEnCours); + $em->persist($task); + $em->flush(); + + $client->request('POST', '/api/projects/' . $project->getId() . '/switch-workflow', [ + 'json' => [ + 'workflowId' => $workflowB->getId(), + 'mapping' => [ + (string) $standardEnCours->getId() => $statusBDev->getId(), + ], + ], + ]); + + self::assertResponseIsSuccessful(); + self::assertJsonContains(['migratedTaskCount' => 1]); + + $em->refresh($task); + $em->refresh($project); + self::assertSame($workflowB->getId(), $project->getWorkflow()->getId()); + self::assertSame($statusBDev->getId(), $task->getStatus()->getId()); + } + + public function testSwitchFailsWhenMappingIsIncomplete(): void + { + $client = self::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + + $workflowB = (new Workflow())->setName('Solo')->setPosition(2); + $statusBOnly = (new TaskStatus())->setLabel('Only')->setColor('#FFF')->setPosition(0)->setCategory(StatusCategory::Todo); + $workflowB->addStatus($statusBOnly); + $em->persist($workflowB); + + $project = $em->getRepository(Project::class)->findOneBy([]); + $standardEnCours = $em->getRepository(TaskStatus::class)->findOneBy([ + 'label' => 'En cours', + 'workflow' => $project->getWorkflow(), + ]); + $em->persist((new Task())->setNumber(9998)->setTitle('Uncovered')->setProject($project)->setStatus($standardEnCours)); + $em->flush(); + + $client->request('POST', '/api/projects/' . $project->getId() . '/switch-workflow', [ + 'json' => [ + 'workflowId' => $workflowB->getId(), + 'mapping' => [], // mapping vide + ], + ]); + + self::assertResponseStatusCodeSame(422); + } +} +``` + +- [ ] **Step 2: Lancer (FAIL attendu)** + +```bash +make test -- --filter SwitchProjectWorkflowTest +``` + +- [ ] **Step 3: Créer le DTO de sortie** + +`src/ApiResource/SwitchWorkflowOutput.php` : + +```php +projectId = $projectId; + $this->workflowId = $workflowId; + $this->migratedTaskCount = $migratedTaskCount; + } +} +``` + +- [ ] **Step 4: Implémenter le processor** + +```php + + */ +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, + ); + } +} +``` + +- [ ] **Step 5: Lancer le test (toujours FAIL : il manque l'endpoint exposé)** + +Continuer à la tâche suivante. + +--- + +## Task 15: Exposer l'endpoint /switch-workflow + +**Files:** +- Modify: `src/Entity/Project.php` + +- [ ] **Step 1: Ajouter l'opération Post custom** + +Dans `src/Entity/Project.php`, ajouter en haut des imports : + +```php +use ApiPlatform\Metadata\Link; +use App\State\SwitchProjectWorkflowProcessor; +``` + +Modifier la liste des `operations` de `#[ApiResource]` en ajoutant après le `Delete` : + +```php + 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, + write: false, + name: 'switch_workflow', + ), +``` + +> `input: false` désactive la deserialization (on lit le body brut dans le processor). `output: SwitchWorkflowOutput::class` indique à API Platform que la réponse a la forme du DTO — la sérialisation utilise le groupe `switch_workflow:read`. `read: true` charge l'entité Project depuis l'id de l'URL. + +- [ ] **Step 2: Lancer les tests du switch (PASS attendu)** + +```bash +make test -- --filter SwitchProjectWorkflowTest +``` + +Expected: 2 tests verts. + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/Project.php src/State/SwitchProjectWorkflowProcessor.php src/ApiResource/SwitchWorkflowOutput.php tests/Functional/SwitchProjectWorkflowTest.php +git commit -m "feat(workflow) : endpoint POST /projects/{id}/switch-workflow + processor transactionnel" +``` + +--- + +## Phase 4 — Frontend : DTOs + service workflows + +## Task 16: DTO Workflow + extension TaskStatus + Project + +**Files:** +- Create: `frontend/services/dto/workflow.ts` +- Modify: `frontend/services/dto/task-status.ts` +- Modify: `frontend/services/dto/project.ts` + +- [ ] **Step 1: Créer workflow.ts** + +```ts +import type { TaskStatus, TaskStatusWrite } from './task-status' + +export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done' + +export const STATUS_CATEGORY_LABEL: Record = { + 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[] +} +``` + +- [ ] **Step 2: Étendre task-status.ts** + +```ts +import type { StatusCategory } from './workflow' + +export type TaskStatus = { + id: number + '@id'?: string + label: string + color: string + position: number + isFinal: boolean + category: StatusCategory + workflow?: { '@id': string, id: number } | string +} + +export type TaskStatusWrite = { + label: string + color: string + position: number + isFinal: boolean + category: StatusCategory + workflow?: string // IRI : "/api/workflows/1" +} +``` + +- [ ] **Step 3: Étendre project.ts** + +Ajouter l'import : + +```ts +import type { Workflow } from './workflow' +``` + +Étendre les types : + +```ts +export type Project = { + id: number + '@id'?: string + code: string + name: string + description: string | null + color: string + client: Client | null + workflow: Workflow + giteaOwner: string | null + giteaRepo: string | null + bookstackShelfId: number | null + bookstackShelfName: string | null + archived: boolean + taskCount: number +} + +export type ProjectWrite = { + code?: string + name: string + description: string | null + color: string + client: string | null + workflow?: string // IRI : "/api/workflows/1" + giteaOwner?: string | null + giteaRepo?: string | null + bookstackShelfId?: number | null + bookstackShelfName?: string | null + archived?: boolean +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/services/dto/workflow.ts frontend/services/dto/task-status.ts frontend/services/dto/project.ts +git commit -m "feat(workflow) : DTOs front Workflow + category sur TaskStatus + workflow embarqué sur Project" +``` + +--- + +## Task 17: Service workflows.ts + +**Files:** +- Create: `frontend/services/workflows.ts` + +- [ ] **Step 1: Écrire le service** + +```ts +import type { Workflow, WorkflowWrite } from './dto/workflow' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +type SwitchPayload = { + workflowId: number + mapping: Record +} + +export function useWorkflowService() { + const api = useApi() + + async function getAll(): Promise { + const data = await api.get>('/workflows') + return extractHydraMembers(data) + } + + async function getOne(id: number): Promise { + return api.get(`/workflows/${id}`) + } + + async function create(payload: WorkflowWrite): Promise { + return api.post('/workflows', payload as Record, { + toastSuccessKey: 'workflows.created', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/workflows/${id}`, payload as Record, { + toastSuccessKey: 'workflows.updated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/workflows/${id}`, {}, { + toastSuccessKey: 'workflows.deleted', + }) + } + + async function switchOnProject(projectId: number, payload: SwitchPayload): Promise<{ migratedTaskCount: number }> { + return api.post<{ migratedTaskCount: number }>( + `/projects/${projectId}/switch-workflow`, + payload as unknown as Record, + { toastSuccessKey: 'workflows.switched' }, + ) + } + + return { getAll, getOne, create, update, remove, switchOnProject } +} +``` + +- [ ] **Step 2: Ajouter les traductions** + +Dans `frontend/i18n/locales/fr.json`, ajouter la section : + +```json + "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é" + } + } +``` + +Dans `frontend/i18n/locales/en.json`, ajouter l'équivalent anglais (mêmes clés, libellés anglais). + +- [ ] **Step 3: Commit** + +```bash +git add frontend/services/workflows.ts frontend/i18n/locales/fr.json frontend/i18n/locales/en.json +git commit -m "feat(workflow) : service front workflows + i18n" +``` + +--- + +## Phase 5 — Admin UI + +## Task 18: WorkflowDrawer (création/édition workflow + statuts inline) + +**Files:** +- Create: `frontend/components/admin/WorkflowDrawer.vue` + +- [ ] **Step 1: Écrire le composant** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/admin/WorkflowDrawer.vue +git commit -m "feat(workflow) : WorkflowDrawer - création/édition workflow et statuts inline" +``` + +--- + +## Task 19: AdminWorkflowTab + +**Files:** +- Create: `frontend/components/admin/AdminWorkflowTab.vue` + +- [ ] **Step 1: Écrire le composant** + +```vue + + + +``` + +> Note : la colonne `isDefault` utilise la slot `cell-isDefault` (cf. DataTable patterns existants comme `cell-color` dans AdminStatusTab). Si le typage de `DataTableColumn.label` n'accepte pas une fonction, remplacer par une string statique `t('workflows.isDefault')` calculée hors du tableau littéral. + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/admin/AdminWorkflowTab.vue +git commit -m "feat(workflow) : AdminWorkflowTab - liste et gestion des workflows" +``` + +--- + +## Task 20: Remplacer l'onglet Statuts par l'onglet Workflows + +**Files:** +- Modify: `frontend/pages/admin.vue` +- Delete: `frontend/components/admin/AdminStatusTab.vue` + +- [ ] **Step 1: Mettre à jour admin.vue** + +Dans `frontend/pages/admin.vue`, remplacer la ligne `` par : + +```vue + +``` + +Et dans la liste `tabs`, remplacer `{ key: 'statuses', label: 'Statuts' }` par : + +```ts + { key: 'workflows', label: 'Workflows' }, +``` + +- [ ] **Step 2: Supprimer le fichier obsolète** + +```bash +git rm frontend/components/admin/AdminStatusTab.vue +``` + +- [ ] **Step 3: Smoke test** + +```bash +make dev-nuxt +``` + +Naviguer sur `http://localhost:3002/admin`, vérifier que l'onglet "Workflows" remplace "Statuts", et que la création/édition d'un workflow fonctionne. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/pages/admin.vue +git commit -m "feat(workflow) : remplace l'onglet Statuts par Workflows en admin" +``` + +--- + +## Phase 6 — Kanban projet + archives projet + +## Task 21: Adapter projects/[id]/index.vue (kanban basé sur workflow du projet) + +**Files:** +- Modify: `frontend/pages/projects/[id]/index.vue` + +- [ ] **Step 1: Lire la page actuelle pour repérer l'usage de statuses** + +```bash +grep -n "statuses\|statusService" frontend/pages/projects/[id]/index.vue +``` + +- [ ] **Step 2: Modifier le chargement** + +Remplacer l'appel `statusService.getAll()` par l'utilisation de `project.workflow.statuses` chargé via `projectService.getOne(id)`. Précisément : + +- Supprimer l'import `useTaskStatusService` et son usage. +- Après chargement du `project` courant, faire : + +```ts +const statuses = computed(() => + [...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position), +) +``` + +(adapter à la variable qui contient le projet ; si la page charge la collection complète, faire un `find` par `route.params.id`). + +> Si le code actuel utilise un `ref` pour `statuses`, remplacer par un `computed` qui dérive de `project.workflow.statuses`. + +- [ ] **Step 3: Smoke test browser** + +Logger en admin, ouvrir un projet, vérifier que les colonnes du kanban correspondent aux statuts du workflow Standard (À faire / En cours / Bloqué / En attente validation / Terminé). + +Ensuite, créer un nouveau workflow "DevKanban" via l'admin avec 3 statuts custom, l'assigner manuellement à un projet (via la console DB pour cette étape — la modal Switch arrive en Task 24), et vérifier que le kanban affiche les 3 nouvelles colonnes. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/pages/projects/[id]/index.vue +git commit -m "feat(workflow) : kanban projet basé sur les statuts du workflow du projet" +``` + +--- + +## Task 22: Adapter projects/[id]/archives.vue + +**Files:** +- Modify: `frontend/pages/projects/[id]/archives.vue` + +- [ ] **Step 1: Filtre statut limité au workflow du projet** + +Localiser le filtre statut (souvent un `MalioSelect` basé sur `statuses`). Le brancher sur `project.workflow.statuses` au lieu de la collection globale (même pattern que Task 21). + +- [ ] **Step 2: Smoke test** + +Naviguer sur `/projects//archives`, vérifier que le dropdown statut n'affiche que les statuts du workflow du projet. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/pages/projects/[id]/archives.vue +git commit -m "feat(workflow) : archives projet - filtre statut limité au workflow du projet" +``` + +--- + +## Phase 7 — my-tasks : kanban groupé par catégorie + +## Task 23: Refactor my-tasks.vue en kanban par catégorie + badge statut sur TaskCard + +**Files:** +- Modify: `frontend/pages/my-tasks.vue` +- Modify: `frontend/components/task/TaskCard.vue` + +- [ ] **Step 1: Ajouter une prop `showStatusBadge` à TaskCard** + +Dans `frontend/components/task/TaskCard.vue`, ajouter dans les props : + +```ts +const props = defineProps<{ + task: Task + showProjectColor?: boolean + showStatusBadge?: boolean +}>() +``` + +Et dans le template, là où l'on affiche déjà des badges (groupe/priorité), ajouter conditionnellement : + +```vue + + {{ task.status.label }} + +``` + +- [ ] **Step 2: Refactor my-tasks.vue** + +Remplacer la section "Kanban helpers" et le template "Kanban View" par : + +```ts +import type { StatusCategory } from '~/services/dto/workflow' +import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow' + +const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done'] + +function tasksByCategory(category: StatusCategory): Task[] { + return tasks.value.filter(t => t.status?.category === category) +} + +// Backlog = tâches sans statut, identique à avant +const backlogTasks = computed(() => tasks.value.filter(t => !t.status)) +``` + +Supprimer `sortedStatuses` et `tasksByStatus`. + +Dans le template, remplacer le bloc "Kanban View" (`
`) par : + +```vue +
+
+
+
+ {{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }}) +
+
+
+ +

+ {{ $t('myTasks.noTasks') }} +

+
+
+
+
+ + +
+

{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})

+
+ +
+

+ {{ $t('myTasks.noTasks') }} +

+
+
+``` + +> Drag & drop : on supprime le drag-to-status (changer la catégorie n'a pas de sens car la catégorie est dérivée du statut). Si Matthieu veut garder un changement de statut depuis my-tasks, ce sera via TaskModal. Supprimer les handlers `onDragEnter/onDragLeave/onDropStatus/onDropBacklog` et les attributs `@dragenter/@dragleave/@drop` du template. + +- [ ] **Step 3: Smoke test** + +Logger en user normal, aller sur `/my-tasks`, vérifier les 5 colonnes par catégorie et le badge statut sur chaque card. Vérifier que les tâches de projets différents (donc workflows potentiellement différents) cohabitent bien dans les colonnes. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/pages/my-tasks.vue frontend/components/task/TaskCard.vue +git commit -m "feat(workflow) : my-tasks - kanban groupé par catégorie avec badge statut" +``` + +--- + +## Phase 8 — Modal Switch + intégration ProjectDrawer + +## Task 24: ProjectWorkflowSwitchModal + +**Files:** +- Create: `frontend/components/project/ProjectWorkflowSwitchModal.vue` + +- [ ] **Step 1: Écrire le composant** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/project/ProjectWorkflowSwitchModal.vue +git commit -m "feat(workflow) : ProjectWorkflowSwitchModal - mapping source→cible avec pré-remplissage par catégorie" +``` + +--- + +## Task 25: Intégrer la modal dans ProjectDrawer + +**Files:** +- Modify: `frontend/components/project/ProjectDrawer.vue` + +- [ ] **Step 1: Afficher le workflow + bouton "Changer"** + +Dans `ProjectDrawer.vue`, ajouter une section "Workflow" sous les autres champs (ou à l'endroit qui convient stylistiquement) : + +```vue +
+
+
+

Workflow

+

{{ props.item.workflow.name }}

+
+ +
+
+ + +``` + +Dans ` + + From 930e1a1e37a9be87e700de197d3669e98ebf4404 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 19 May 2026 21:12:05 +0200 Subject: [PATCH 28/28] =?UTF-8?q?fix(help)=20:=20retire=20definePageMeta?= =?UTF-8?q?=20auth=20(middleware=20global=20d=C3=A9j=C3=A0=20appliqu=C3=A9?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/pages/help.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/pages/help.vue b/frontend/pages/help.vue index 4b4fde6..24c12c0 100644 --- a/frontend/pages/help.vue +++ b/frontend/pages/help.vue @@ -1,7 +1,6 @@