# 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 `