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 `