Files
Lesstime/docs/superpowers/plans/2026-05-19-project-workflows.md

95 KiB

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<ts>_create_workflow.php Create Migration M1
migrations/Version<ts>_add_workflow_to_task_status.php Create Migration M2 (backfill + NOT NULL + échec si label inconnu)
migrations/Version<ts>_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

declare(strict_types=1);

namespace App\Enum;

enum StatusCategory: string
{
    case Todo       = 'todo';
    case InProgress = 'in_progress';
    case Blocked    = 'blocked';
    case Review     = 'review';
    case Done       = 'done';
}
  • Step 2: Commit
git add src/Enum/StatusCategory.php
git commit -m "feat(workflow) : ajoute l'enum StatusCategory (5 catégories canoniques)"

Task 2: Entité Workflow + Repository

Files:

  • Create: src/Entity/Workflow.php

  • Create: src/Repository/WorkflowRepository.php

  • Step 1: Repository

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\Workflow;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class WorkflowRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Workflow::class);
    }

    public function findDefault(): ?Workflow
    {
        return $this->findOneBy(['isDefault' => true]);
    }
}
  • Step 2: Entité Workflow
<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\WorkflowRepository;
use App\State\WorkflowDeleteProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(
    operations: [
        new GetCollection(security: "is_granted('ROLE_USER')"),
        new Get(security: "is_granted('ROLE_USER')"),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')", processor: WorkflowDeleteProcessor::class),
    ],
    normalizationContext: ['groups' => ['workflow:read']],
    denormalizationContext: ['groups' => ['workflow:write']],
    order: ['position' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: WorkflowRepository::class)]
#[UniqueEntity(fields: ['name'], message: 'Ce nom de workflow est déjà utilisé.')]
class Workflow
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['workflow:read', 'project:read', 'task_status:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255, unique: true)]
    #[Groups(['workflow:read', 'workflow:write', 'project:read'])]
    #[Assert\NotBlank]
    private ?string $name = null;

    #[ORM\Column(type: 'boolean', options: ['default' => false])]
    #[Groups(['workflow:read', 'workflow:write'])]
    private bool $isDefault = false;

    #[ORM\Column(type: 'integer', options: ['default' => 0])]
    #[Groups(['workflow:read', 'workflow:write'])]
    private int $position = 0;

    /** @var Collection<int, TaskStatus> */
    #[ORM\OneToMany(targetEntity: TaskStatus::class, mappedBy: 'workflow', cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[ORM\OrderBy(['position' => 'ASC'])]
    #[Groups(['workflow:read', 'project:read'])]
    private Collection $statuses;

    public function __construct()
    {
        $this->statuses = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    public function isDefault(): bool
    {
        return $this->isDefault;
    }

    public function setIsDefault(bool $isDefault): static
    {
        $this->isDefault = $isDefault;

        return $this;
    }

    public function getPosition(): int
    {
        return $this->position;
    }

    public function setPosition(int $position): static
    {
        $this->position = $position;

        return $this;
    }

    /** @return Collection<int, TaskStatus> */
    public function getStatuses(): Collection
    {
        return $this->statuses;
    }

    public function addStatus(TaskStatus $status): static
    {
        if (!$this->statuses->contains($status)) {
            $this->statuses->add($status);
            $status->setWorkflow($this);
        }

        return $this;
    }

    public function removeStatus(TaskStatus $status): static
    {
        $this->statuses->removeElement($status);

        return $this;
    }
}
  • Step 3: Commit
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

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\StatusCategory;
use App\Repository\TaskStatusRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(
    operations: [
        new GetCollection(security: "is_granted('ROLE_USER')"),
        new Get(security: "is_granted('ROLE_USER')"),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['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
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; :

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

    #[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) :

    public function getWorkflow(): ?Workflow
    {
        return $this->workflow;
    }

    public function setWorkflow(Workflow $workflow): static
    {
        $this->workflow = $workflow;

        return $this;
    }
  • Step 2: Commit
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<timestamp>_create_workflow.php

  • Step 1: Générer le squelette

docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate

Renommer le fichier généré en Version<timestamp>_create_workflow.php pour clarté (Doctrine n'impose pas le nom).

  • Step 2: Remplir le up/down
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version<timestamp> 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
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:list

Expected: la nouvelle migration apparaît "Not migrated".

  • Step 4: Commit
git add migrations/Version<timestamp>_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<timestamp>_add_workflow_to_task_status.php

  • Step 1: Générer + remplir

docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\MigrationException;

final class Version<timestamp> 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
git add migrations/Version<timestamp>_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<timestamp>_add_workflow_to_project.php

  • Step 1: Générer + remplir

docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\MigrationException;

final class Version<timestamp> 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
git add migrations/Version<timestamp>_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 :

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 :

        // 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 :

        $project<X>->setWorkflow($standardWorkflow);

(remplacer <X> par le nom de variable du projet : $projectClient, $projectInterne, etc.)

  • Step 4: Commit
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

declare(strict_types=1);

namespace App\EventListener;

use App\Entity\Workflow;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;

#[AsDoctrineListener(event: Events::onFlush)]
final class UniqueDefaultWorkflowListener
{
    public function onFlush(OnFlushEventArgs $args): void
    {
        $em  = $args->getObjectManager();
        $uow = $em->getUnitOfWork();

        $candidates = [];
        foreach ($uow->getScheduledEntityInsertions() as $entity) {
            if ($entity instanceof Workflow && $entity->isDefault()) {
                $candidates[] = $entity;
            }
        }
        foreach ($uow->getScheduledEntityUpdates() as $entity) {
            if ($entity instanceof Workflow && $entity->isDefault()) {
                $candidates[] = $entity;
            }
        }

        if (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
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
make migration-migrate

Expected: les 3 migrations passent vert.

  • Step 2: Vérifier le schéma
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
make fixtures

Expected: pas d'erreur.

  • Step 4: Vérifier le contenu via psql
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
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) :

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

declare(strict_types=1);

namespace App\Tests\Functional;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Workflow;
use Doctrine\ORM\EntityManagerInterface;

final class WorkflowDeleteProtectionTest extends ApiTestCase
{
    public function testDeleteWorkflowWithLinkedProjectsReturns409(): void
    {
        $client = self::createClient();
        $client->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)
make test -- --filter WorkflowDeleteProtectionTest

Expected: échec (le processor n'existe pas encore).

  • Step 3: Implémenter le processor
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Workflow;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;

/**
 * @implements ProcessorInterface<Workflow, void>
 */
final readonly class WorkflowDeleteProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
    {
        /** @var Workflow $workflow */
        $workflow = $data;

        $count = (int) $this->entityManager->getConnection()->fetchOne(
            'SELECT COUNT(*) FROM project WHERE workflow_id = :id',
            ['id' => $workflow->getId()],
        );

        if ($count > 0) {
            throw new HttpException(409, sprintf(
                'Workflow used by %d project(s). Reassign them before deleting.',
                $count,
            ));
        }

        $this->entityManager->remove($workflow);
        $this->entityManager->flush();
    }
}
  • Step 4: Lancer le test (PASS attendu)
make test -- --filter WorkflowDeleteProtectionTest

Expected: green.

  • Step 5: Commit
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

declare(strict_types=1);

namespace App\Tests\Functional;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Project;
use App\Entity\TaskStatus;
use App\Entity\User;
use App\Entity\Workflow;
use App\Enum\StatusCategory;
use Doctrine\ORM\EntityManagerInterface;

final class TaskWorkflowValidationTest extends ApiTestCase
{
    public function testCannotAssignStatusFromAnotherWorkflowOnTaskCreation(): void
    {
        $client = self::createClient();
        $em     = static::getContainer()->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
make test -- --filter TaskWorkflowValidationTest
  • Step 3: Commit
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

declare(strict_types=1);

namespace App\Tests\Functional;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskStatus;
use App\Entity\User;
use App\Entity\Workflow;
use App\Enum\StatusCategory;
use Doctrine\ORM\EntityManagerInterface;

final class SwitchProjectWorkflowTest extends ApiTestCase
{
    public function testSwitchMigratesAllTasksAccordingToMapping(): void
    {
        $client = self::createClient();
        $em     = static::getContainer()->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)
make test -- --filter SwitchProjectWorkflowTest
  • Step 3: Créer le DTO de sortie

src/ApiResource/SwitchWorkflowOutput.php :

<?php

declare(strict_types=1);

namespace App\ApiResource;

use Symfony\Component\Serializer\Attribute\Groups;

final class SwitchWorkflowOutput
{
    #[Groups(['switch_workflow:read'])]
    public int $projectId;

    #[Groups(['switch_workflow:read'])]
    public int $workflowId;

    #[Groups(['switch_workflow:read'])]
    public int $migratedTaskCount;

    public function __construct(int $projectId, int $workflowId, int $migratedTaskCount)
    {
        $this->projectId         = $projectId;
        $this->workflowId        = $workflowId;
        $this->migratedTaskCount = $migratedTaskCount;
    }
}
  • Step 4: Implémenter le processor
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\SwitchWorkflowOutput;
use App\Entity\Project;
use App\Entity\TaskStatus;
use App\Entity\Workflow;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Wraps the switch-workflow operation for a project.
 * Input: Project (URI variable) + body { workflowId, mapping: { sourceStatusId: targetStatusId|null } }
 *
 * @implements ProcessorInterface<Project, SwitchWorkflowOutput>
 */
final readonly class SwitchProjectWorkflowProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SwitchWorkflowOutput
    {
        /** @var Project $project */
        $project = $data;

        $request = $context['request'] ?? null;
        $body    = $request ? json_decode($request->getContent(), true) : [];

        $workflowId = $body['workflowId'] ?? null;
        $mapping    = $body['mapping']    ?? [];

        if (!is_int($workflowId) || !is_array($mapping)) {
            throw new HttpException(422, 'Body must contain workflowId (int) and mapping (object).');
        }

        $targetWorkflow = $this->entityManager->find(Workflow::class, $workflowId);
        if (!$targetWorkflow instanceof Workflow) {
            throw new NotFoundHttpException('Target workflow not found.');
        }

        // 1) Lister les statuts source effectivement référencés par les tâches du projet
        $rows = $this->entityManager->getConnection()->fetchAllAssociative(
            'SELECT DISTINCT status_id FROM task WHERE project_id = :pid AND status_id IS NOT NULL',
            ['pid' => $project->getId()],
        );
        $referencedSourceIds = array_map(static fn ($r) => (int) $r['status_id'], $rows);

        // 2) Vérifier que chaque source a un mapping
        $missing = [];
        foreach ($referencedSourceIds as $srcId) {
            if (!array_key_exists((string) $srcId, $mapping)) {
                $missing[] = $srcId;
            }
        }
        if ($missing !== []) {
            throw new HttpException(422, 'Missing mapping for source status IDs: ' . implode(', ', $missing));
        }

        // 3) Valider que chaque target appartient au workflow cible (ou est null)
        foreach ($mapping as $srcId => $targetId) {
            if (null === $targetId) {
                continue;
            }
            $target = $this->entityManager->find(TaskStatus::class, $targetId);
            if (!$target instanceof TaskStatus
                || $target->getWorkflow()?->getId() !== $targetWorkflow->getId()) {
                throw new HttpException(422, sprintf(
                    'Target status %s does not belong to workflow %d.',
                    var_export($targetId, true),
                    $targetWorkflow->getId(),
                ));
            }
        }

        // 4) Transaction unique
        $conn = $this->entityManager->getConnection();
        $conn->beginTransaction();
        try {
            $migrated = 0;
            foreach ($mapping as $srcId => $targetId) {
                $affected = $conn->executeStatement(
                    'UPDATE task SET status_id = :tid WHERE project_id = :pid AND status_id = :sid',
                    ['tid' => $targetId, 'pid' => $project->getId(), 'sid' => (int) $srcId],
                );
                $migrated += $affected;
            }

            $project->setWorkflow($targetWorkflow);
            $this->entityManager->flush();
            $conn->commit();
        } catch (\Throwable $e) {
            $conn->rollBack();
            throw $e;
        }

        return new SwitchWorkflowOutput(
            projectId: $project->getId(),
            workflowId: $targetWorkflow->getId(),
            migratedTaskCount: $migrated,
        );
    }
}
  • 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 :

use ApiPlatform\Metadata\Link;
use App\State\SwitchProjectWorkflowProcessor;

Modifier la liste des operations de #[ApiResource] en ajoutant après le Delete :

        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)
make test -- --filter SwitchProjectWorkflowTest

Expected: 2 tests verts.

  • Step 3: Commit
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

import type { TaskStatus, TaskStatusWrite } from './task-status'

export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'

export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
    todo: 'À faire',
    in_progress: 'En cours',
    blocked: 'Bloqué',
    review: 'En validation',
    done: 'Terminé',
}

export type Workflow = {
    id: number
    '@id'?: string
    name: string
    isDefault: boolean
    position: number
    statuses: TaskStatus[]
}

export type WorkflowWrite = {
    name: string
    isDefault: boolean
    position: number
    statuses?: TaskStatusWrite[]
}
  • Step 2: Étendre task-status.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 :

import type { Workflow } from './workflow'

Étendre les types :

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

import type { Workflow, WorkflowWrite } from './dto/workflow'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'

type SwitchPayload = {
    workflowId: number
    mapping: Record<string, number | null>
}

export function useWorkflowService() {
    const api = useApi()

    async function getAll(): Promise<Workflow[]> {
        const data = await api.get<HydraCollection<Workflow>>('/workflows')
        return extractHydraMembers(data)
    }

    async function getOne(id: number): Promise<Workflow> {
        return api.get<Workflow>(`/workflows/${id}`)
    }

    async function create(payload: WorkflowWrite): Promise<Workflow> {
        return api.post<Workflow>('/workflows', payload as Record<string, unknown>, {
            toastSuccessKey: 'workflows.created',
        })
    }

    async function update(id: number, payload: Partial<WorkflowWrite>): Promise<Workflow> {
        return api.patch<Workflow>(`/workflows/${id}`, payload as Record<string, unknown>, {
            toastSuccessKey: 'workflows.updated',
        })
    }

    async function remove(id: number): Promise<void> {
        await api.delete(`/workflows/${id}`, {}, {
            toastSuccessKey: 'workflows.deleted',
        })
    }

    async function switchOnProject(projectId: number, payload: SwitchPayload): Promise<{ migratedTaskCount: number }> {
        return api.post<{ migratedTaskCount: number }>(
            `/projects/${projectId}/switch-workflow`,
            payload as unknown as Record<string, unknown>,
            { toastSuccessKey: 'workflows.switched' },
        )
    }

    return { getAll, getOne, create, update, remove, switchOnProject }
}
  • Step 2: Ajouter les traductions

Dans frontend/i18n/locales/fr.json, ajouter la section :

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

<template>
    <MalioDrawer v-model="isOpen" :title="isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow')">
        <form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
            <MalioInputText
                v-model="form.name"
                :label="$t('workflows.name')"
                input-class="w-full"
                :error="touched.name && !form.name.trim() ? $t('workflows.name') + ' requis' : ''"
                @blur="touched.name = true"
            />

            <div class="flex items-center gap-2">
                <input
                    id="isDefault"
                    v-model="form.isDefault"
                    type="checkbox"
                    class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
                />
                <label for="isDefault" class="text-sm font-medium text-neutral-700">
                    {{ $t('workflows.isDefault') }}
                </label>
            </div>

            <div class="mt-2">
                <div class="flex items-center justify-between">
                    <h3 class="text-sm font-bold text-neutral-900">{{ $t('workflows.statuses') }}</h3>
                    <MalioButton
                        type="button"
                        icon-name="mdi:plus"
                        icon-position="left"
                        button-class="w-auto px-3 py-1 text-xs"
                        :label="$t('workflows.addStatus')"
                        @click="addStatus"
                    />
                </div>

                <div class="mt-3 flex flex-col gap-3">
                    <div
                        v-for="(s, idx) in form.statuses"
                        :key="idx"
                        class="rounded border border-neutral-200 p-3"
                    >
                        <div class="flex items-end gap-2">
                            <MalioInputText
                                v-model="s.label"
                                label="Libellé"
                                input-class="w-full"
                            />
                            <select
                                v-model="s.category"
                                class="h-10 rounded border border-neutral-300 px-2 text-sm"
                                aria-label="Catégorie"
                            >
                                <option v-for="c in categoryOptions" :key="c.value" :value="c.value">
                                    {{ c.label }}
                                </option>
                            </select>
                            <button
                                type="button"
                                class="h-10 px-2 text-red-600 hover:text-red-800"
                                aria-label="Supprimer"
                                @click="removeStatus(idx)"
                            >
                                <Icon name="mdi:delete" size="20" />
                            </button>
                        </div>
                        <div class="mt-2 flex items-center gap-3">
                            <ColorPicker v-model="s.color" />
                            <label class="ml-auto flex items-center gap-1 text-xs text-neutral-700">
                                <input v-model="s.isFinal" type="checkbox" class="h-3 w-3" />
                                {{ $t('archive.statusFinal') }}
                            </label>
                            <MalioInputText
                                v-model.number="s.position"
                                label="Position"
                                input-class="!w-16"
                                type="number"
                            />
                        </div>
                    </div>
                </div>
            </div>

            <div class="mt-4 flex justify-end">
                <MalioButton
                    label="Enregistrer"
                    button-class="w-auto px-6"
                    :disabled="isSubmitting"
                    @click="handleSubmit"
                />
            </div>
        </form>
    </MalioDrawer>
</template>

<script setup lang="ts">
import type { Workflow } from '~/services/dto/workflow'
import type { StatusCategory } from '~/services/dto/workflow'
import type { TaskStatusWrite } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskStatusService } from '~/services/task-statuses'

const { t } = useI18n()

const props = defineProps<{
    modelValue: boolean
    item: Workflow | null
}>()

const emit = defineEmits<{
    (e: 'update:modelValue', value: boolean): void
    (e: 'saved'): void
}>()

const isOpen = computed({
    get: () => props.modelValue,
    set: (v) => emit('update:modelValue', v),
})

const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)

type StatusForm = {
    id?: number
    label: string
    color: string
    position: number
    isFinal: boolean
    category: StatusCategory
}

const form = reactive<{
    name: string
    isDefault: boolean
    statuses: StatusForm[]
}>({
    name: '',
    isDefault: false,
    statuses: [],
})

const touched = reactive({ name: false })

const categoryOptions: { value: StatusCategory, label: string }[] = [
    { value: 'todo',        label: t('workflows.categories.todo') },
    { value: 'in_progress', label: t('workflows.categories.in_progress') },
    { value: 'blocked',     label: t('workflows.categories.blocked') },
    { value: 'review',      label: t('workflows.categories.review') },
    { value: 'done',        label: t('workflows.categories.done') },
]

watch(() => props.modelValue, (open) => {
    if (!open) return
    if (props.item) {
        form.name = props.item.name
        form.isDefault = props.item.isDefault
        form.statuses = props.item.statuses.map(s => ({
            id: s.id,
            label: s.label,
            color: s.color,
            position: s.position,
            isFinal: s.isFinal,
            category: s.category,
        }))
    } else {
        form.name = ''
        form.isDefault = false
        form.statuses = []
    }
    touched.name = false
})

function addStatus() {
    form.statuses.push({
        label: '',
        color: '#222783',
        position: form.statuses.length,
        isFinal: false,
        category: 'todo',
    })
}

function removeStatus(idx: number) {
    form.statuses.splice(idx, 1)
}

const workflowService = useWorkflowService()
const statusService = useTaskStatusService()

async function handleSubmit() {
    touched.name = true
    if (!form.name.trim()) return

    isSubmitting.value = true
    try {
        if (isEditing.value && props.item) {
            await workflowService.update(props.item.id, {
                name: form.name.trim(),
                isDefault: form.isDefault,
                position: props.item.position,
            })
            // Sync statuses one-by-one (create/update/delete)
            await syncStatuses(props.item)
        } else {
            // Création : on crée le workflow d'abord, puis ses statuts
            const created = await workflowService.create({
                name: form.name.trim(),
                isDefault: form.isDefault,
                position: 0,
            })
            for (const s of form.statuses) {
                const payload: TaskStatusWrite = {
                    label: s.label,
                    color: s.color,
                    position: s.position,
                    isFinal: s.isFinal,
                    category: s.category,
                    workflow: `/api/workflows/${created.id}`,
                }
                await statusService.create(payload)
            }
        }
        emit('saved')
        isOpen.value = false
    } finally {
        isSubmitting.value = false
    }
}

async function syncStatuses(workflow: Workflow) {
    const existingIds = new Set(workflow.statuses.map(s => s.id))
    const keptIds = new Set<number>()

    for (const s of form.statuses) {
        if (s.id) {
            keptIds.add(s.id)
            await statusService.update(s.id, {
                label: s.label,
                color: s.color,
                position: s.position,
                isFinal: s.isFinal,
                category: s.category,
            })
        } else {
            await statusService.create({
                label: s.label,
                color: s.color,
                position: s.position,
                isFinal: s.isFinal,
                category: s.category,
                workflow: `/api/workflows/${workflow.id}`,
            })
        }
    }

    // Supprimer les statuts retirés
    for (const id of existingIds) {
        if (id && !keptIds.has(id)) {
            await statusService.remove(id)
        }
    }
}
</script>
  • Step 2: Commit
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

<template>
    <div>
        <div class="flex items-center justify-between">
            <h2 class="text-lg font-bold text-neutral-900">{{ $t('workflows.title') }}</h2>
            <MalioButton
                icon-name="mdi:plus"
                icon-position="left"
                button-class="w-auto px-4"
                :label="$t('workflows.addWorkflow')"
                @click="openCreate"
            />
        </div>

        <DataTable
            :columns="columns"
            :items="items"
            :loading="isLoading"
            empty-message="Aucun workflow trouvé."
            deletable
            @row-click="openEdit"
            @delete="requestDelete"
        >
            <template #cell-isDefault="{ item }">
                <span v-if="item.isDefault" class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700">
                    {{ $t('workflows.isDefault') }}
                </span>
            </template>
            <template #cell-statusCount="{ item }">
                {{ item.statuses.length }}
            </template>
        </DataTable>

        <WorkflowDrawer
            v-model="drawerOpen"
            :item="selectedItem"
            @saved="onSaved"
        />
    </div>
</template>

<script setup lang="ts">
import type { Workflow } from '~/services/dto/workflow'
import { useWorkflowService } from '~/services/workflows'
import { useNotify } from '~/composables/useApi'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'

const { t } = useI18n()

const columns: DataTableColumn[] = [
    { key: 'name',        label: t('workflows.name'),     primary: true },
    { key: 'isDefault',   label: $t => $t('workflows.isDefault') as unknown as string },
    { key: 'statusCount', label: 'Statuts' },
    { key: 'position',    label: 'Position' },
]

const workflowService = useWorkflowService()

const items = ref<Workflow[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<Workflow | null>(null)

async function loadItems() {
    isLoading.value = true
    try {
        items.value = await workflowService.getAll()
    } finally {
        isLoading.value = false
    }
}

function openCreate() {
    selectedItem.value = null
    drawerOpen.value = true
}

function openEdit(item: Workflow) {
    selectedItem.value = item
    drawerOpen.value = true
}

async function requestDelete(item: Workflow) {
    try {
        await workflowService.remove(item.id)
        await loadItems()
    } catch (err: any) {
        // Le toast d'erreur est déjà émis par useApi ; rien à faire ici.
    }
}

async function onSaved() {
    await loadItems()
}

onMounted(() => {
    loadItems()
})
</script>

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
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 <AdminStatusTab v-if="activeTab === 'statuses'" /> par :

            <AdminWorkflowTab v-if="activeTab === 'workflows'" />

Et dans la liste tabs, remplacer { key: 'statuses', label: 'Statuts' } par :

    { key: 'workflows', label: 'Workflows' },
  • Step 2: Supprimer le fichier obsolète
git rm frontend/components/admin/AdminStatusTab.vue
  • Step 3: Smoke test
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
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

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 :
const statuses = computed<TaskStatus[]>(() =>
    [...(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<TaskStatus[]> 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
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/<id>/archives, vérifier que le dropdown statut n'affiche que les statuts du workflow du projet.

  • Step 3: Commit
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 :

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 :

<span
    v-if="showStatusBadge && task.status"
    class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium text-white"
    :style="{ backgroundColor: task.status.color }"
>
    {{ task.status.label }}
</span>
  • Step 2: Refactor my-tasks.vue

Remplacer la section "Kanban helpers" et le template "Kanban View" par :

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" (<div v-if="viewMode === 'kanban'">) par :

<div v-if="viewMode === 'kanban'">
    <div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
        <div
            v-for="cat in CATEGORIES"
            :key="cat"
            class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50"
        >
            <div class="shrink-0 rounded-t-lg bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
                {{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
            </div>
            <div class="min-h-0 flex-1 overflow-y-auto p-3">
                <div class="flex flex-col gap-3">
                    <TaskCard
                        v-for="task in tasksByCategory(cat)"
                        :key="task.id"
                        :task="task"
                        show-project-color
                        show-status-badge
                        @click="openTaskEdit(task)"
                    />
                    <p
                        v-if="tasksByCategory(cat).length === 0"
                        class="py-4 text-center text-xs text-neutral-400"
                    >
                        {{ $t('myTasks.noTasks') }}
                    </p>
                </div>
            </div>
        </div>
    </div>

    <!-- Backlog inchangé -->
    <div class="mt-8 rounded-lg bg-neutral-50 p-4">
        <h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
        <div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
            <TaskCard
                v-for="task in backlogTasks"
                :key="task.id"
                :task="task"
                show-project-color
                show-status-badge
                @click="openTaskEdit(task)"
            />
        </div>
        <p
            v-if="backlogTasks.length === 0"
            class="py-4 text-center text-xs text-neutral-400"
        >
            {{ $t('myTasks.noTasks') }}
        </p>
    </div>
</div>

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

<template>
    <MalioModal v-model="isOpen" :title="$t('workflows.switchTitle')" size="lg">
        <div class="flex flex-col gap-5">
            <MalioSelect
                v-model="targetWorkflowId"
                :options="targetOptions"
                :label="$t('workflows.switchTargetLabel')"
                :empty-option-label="'—'"
                min-width="!w-full"
            />

            <div v-if="targetWorkflow" class="flex flex-col gap-2">
                <h3 class="text-sm font-bold text-neutral-900">{{ $t('workflows.switchMappingTitle') }}</h3>

                <table class="w-full text-sm">
                    <thead>
                        <tr class="border-b text-left text-xs text-neutral-500">
                            <th class="py-2 pr-3">{{ $t('workflows.switchSourceCol') }}</th>
                            <th class="py-2 pr-3">{{ $t('workflows.switchTargetCol') }}</th>
                            <th class="py-2 text-right">{{ $t('workflows.switchTaskCountCol') }}</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr v-for="row in mappingRows" :key="row.sourceId ?? 'backlog'" class="border-b last:border-0">
                            <td class="py-2 pr-3">
                                <span
                                    v-if="row.source"
                                    class="mr-2 inline-block h-3 w-3 rounded-full align-middle"
                                    :style="{ backgroundColor: row.source.color }"
                                />
                                {{ row.source?.label ?? $t('myTasks.backlog') }}
                                <span class="ml-1 text-xs text-neutral-400">
                                    ({{ row.source?.category ? $t(`workflows.categories.${row.source.category}`) : '—' }})
                                </span>
                            </td>
                            <td class="py-2 pr-3">
                                <select
                                    v-model="row.targetId"
                                    class="h-9 w-full rounded border border-neutral-300 px-2 text-sm"
                                >
                                    <option :value="null">{{ $t('workflows.switchToBacklog') }}</option>
                                    <option
                                        v-for="s in targetWorkflow.statuses"
                                        :key="s.id"
                                        :value="s.id"
                                    >
                                        {{ s.label }}
                                    </option>
                                </select>
                            </td>
                            <td class="py-2 text-right text-neutral-700">{{ row.count }}</td>
                        </tr>
                    </tbody>
                </table>
            </div>

            <div class="flex justify-end">
                <MalioButton
                    :label="$t('workflows.switchConfirm')"
                    button-class="w-auto px-6"
                    :disabled="!canConfirm || isSubmitting"
                    @click="confirm"
                />
            </div>
        </div>
    </MalioModal>
</template>

<script setup lang="ts">
import type { Project } from '~/services/dto/project'
import type { Task } from '~/services/dto/task'
import type { Workflow } from '~/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskService } from '~/services/tasks'

const { t } = useI18n()

const props = defineProps<{
    modelValue: boolean
    project: Project
}>()

const emit = defineEmits<{
    (e: 'update:modelValue', value: boolean): void
    (e: 'switched'): void
}>()

const isOpen = computed({
    get: () => props.modelValue,
    set: (v) => emit('update:modelValue', v),
})

const workflows = ref<Workflow[]>([])
const projectTasks = ref<Task[]>([])
const targetWorkflowId = ref<number | null>(null)
const isSubmitting = ref(false)

const workflowService = useWorkflowService()
const taskService = useTaskService()

const targetOptions = computed(() =>
    workflows.value
        .filter(w => w.id !== props.project.workflow.id)
        .map(w => ({ label: w.name, value: w.id })),
)

const targetWorkflow = computed<Workflow | null>(() =>
    workflows.value.find(w => w.id === targetWorkflowId.value) ?? null,
)

type Row = {
    sourceId: number | null
    source: TaskStatus | null
    targetId: number | null
    count: number
}

const mappingRows = ref<Row[]>([])

function smartPrefill(source: TaskStatus | null, target: Workflow): number | null {
    if (!source) return null
    const sameCat = target.statuses
        .filter(s => s.category === source.category)
        .sort((a, b) => a.position - b.position)
    return sameCat[0]?.id ?? null
}

watch(targetWorkflow, (tw) => {
    if (!tw) {
        mappingRows.value = []
        return
    }
    const usedStatusIds = new Map<number | null, number>()
    for (const t of projectTasks.value) {
        const key = t.status?.id ?? null
        usedStatusIds.set(key, (usedStatusIds.get(key) ?? 0) + 1)
    }
    mappingRows.value = [...usedStatusIds.entries()].map(([sourceId, count]) => {
        const source = props.project.workflow.statuses.find(s => s.id === sourceId) ?? null
        return {
            sourceId,
            source,
            targetId: smartPrefill(source, tw),
            count,
        }
    })
})

const canConfirm = computed(() => {
    if (!targetWorkflow.value) return false
    // Toutes les sources non-backlog doivent avoir un targetId (null = backlog accepté)
    return mappingRows.value.every(r => r.sourceId === null || r.targetId !== undefined)
})

watch(() => props.modelValue, async (open) => {
    if (!open) return
    targetWorkflowId.value = null
    const [allWorkflows, tasks] = await Promise.all([
        workflowService.getAll(),
        taskService.getFiltered({ project: `/api/projects/${props.project.id}`, archived: false }),
    ])
    workflows.value = allWorkflows
    projectTasks.value = tasks
})

async function confirm() {
    if (!targetWorkflow.value) return
    isSubmitting.value = true
    try {
        const mapping: Record<string, number | null> = {}
        for (const r of mappingRows.value) {
            if (r.sourceId !== null) {
                mapping[String(r.sourceId)] = r.targetId
            }
        }
        await workflowService.switchOnProject(props.project.id, {
            workflowId: targetWorkflow.value.id,
            mapping,
        })
        emit('switched')
        isOpen.value = false
    } finally {
        isSubmitting.value = false
    }
}
</script>
  • Step 2: Commit
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) :

<div v-if="props.item" class="mt-4 rounded border border-neutral-200 p-3">
    <div class="flex items-center justify-between">
        <div>
            <p class="text-xs uppercase text-neutral-500">Workflow</p>
            <p class="text-sm font-semibold text-neutral-900">{{ props.item.workflow.name }}</p>
        </div>
        <MalioButton
            v-if="canManageWorkflows"
            type="button"
            icon-name="mdi:swap-horizontal"
            icon-position="left"
            button-class="w-auto px-3 py-1 text-xs"
            :label="$t('workflows.switchTitle')"
            @click="switchModalOpen = true"
        />
    </div>
</div>

<ProjectWorkflowSwitchModal
    v-if="props.item"
    v-model="switchModalOpen"
    :project="props.item"
    @switched="onWorkflowSwitched"
/>

Dans <script setup> :

const switchModalOpen = ref(false)
const auth = useAuthStore()
const canManageWorkflows = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)

function onWorkflowSwitched() {
    emit('saved')
    isOpen.value = false
}

(Adapter emit('saved') au nom d'event utilisé par ProjectDrawer existant.)

  • Step 2: Smoke test

Logger en admin, ouvrir un projet existant, cliquer "Changer de workflow", choisir un autre workflow, vérifier le pré-remplissage par catégorie, confirmer, et vérifier que le kanban du projet utilise désormais les nouvelles colonnes.

  • Step 3: Commit
git add frontend/components/project/ProjectDrawer.vue
git commit -m "feat(workflow) : ProjectDrawer - section workflow et accès à la modal switch"

Phase 9 — TaskBulkActions

Task 26: Désactiver le bulk-status sur sélection multi-projets

Files:

  • Modify: frontend/components/task/TaskBulkActions.vue

  • Step 1: Lire le composant existant

cat frontend/components/task/TaskBulkActions.vue
  • Step 2: Ajouter une prop selectedTasks et logique

Ajouter la prop selectedTasks: Task[] (au lieu ou en plus de selectedCount).

Dans le script :

const distinctProjectIds = computed(() => {
    const ids = new Set<number>()
    props.selectedTasks.forEach(t => { if (t.project) ids.add(t.project.id) })
    return ids
})
const isMultiProject = computed(() => distinctProjectIds.value.size > 1)

const statusOptionsScoped = computed<{ label: string, value: number }[]>(() => {
    if (isMultiProject.value || distinctProjectIds.value.size === 0) return []
    const projectId = [...distinctProjectIds.value][0]
    const project = props.projects?.find(p => p.id === projectId)
    return (project?.workflow.statuses ?? []).map(s => ({ label: s.label, value: s.id }))
})

Dans le template, désactiver le bouton/select statut quand isMultiProject :

<button
    :disabled="isMultiProject"
    :title="isMultiProject ? 'Sélection multi-projets — statut non disponible' : ''"
    class="..."
>
    Changer le statut
</button>

(adapter à la structure existante du composant — la clé est : passer les projects au composant et tirer les statuses du workflow du projet courant des tâches sélectionnées).

  • Step 3: Propager la prop depuis my-tasks.vue et projects/[id]/index.vue

Là où <TaskBulkActions> est utilisé, passer :

:selected-tasks="selectedTasksArray"
:projects="projects"

(remplacer :statuses="statuses" qui devient obsolète une fois la logique scoped en interne).

  • Step 4: Smoke test

Sur /my-tasks en mode liste, sélectionner 2 tâches d'un même projet → bouton actif. Sélectionner 2 tâches de projets différents → bouton désactivé avec tooltip.

  • Step 5: Commit
git add frontend/components/task/TaskBulkActions.vue frontend/pages/my-tasks.vue frontend/pages/projects/[id]/index.vue
git commit -m "feat(workflow) : bulk status désactivé sur sélection multi-projets, scoped au workflow"

Phase 10 — MCP

Task 27: list-statuses — param projectId optionnel

Files:

  • Modify: src/Mcp/Tool/TaskMeta/ListStatusesTool.php

  • Step 1: Modifier la méthode __invoke

<?php

declare(strict_types=1);

namespace App\Mcp\Tool\TaskMeta;

use App\Entity\Project;
use App\Repository\TaskStatusRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

#[McpTool(
    name: 'list-statuses',
    description: 'List task statuses. With projectId, returns only the statuses of that project\'s workflow. Without projectId, returns ALL statuses across workflows (use list-workflows to see how they group).',
)]
class ListStatusesTool
{
    public function __construct(
        private readonly TaskStatusRepository $taskStatusRepository,
        private readonly EntityManagerInterface $entityManager,
        private readonly Security $security,
    ) {}

    public function __invoke(?int $projectId = null): string
    {
        if (!$this->security->isGranted('ROLE_USER')) {
            throw new AccessDeniedException('Access denied: ROLE_USER required.');
        }

        if (null !== $projectId) {
            $project = $this->entityManager->find(Project::class, $projectId);
            if (!$project) {
                return json_encode(['error' => 'Project not found.']);
            }
            $statuses = $project->getWorkflow()->getStatuses();
        } else {
            $statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
        }

        return json_encode(array_map(fn ($s) => [
            'id'         => $s->getId(),
            'label'      => $s->getLabel(),
            'color'      => $s->getColor(),
            'position'   => $s->getPosition(),
            'isFinal'    => $s->getIsFinal(),
            'category'   => $s->getCategory()->value,
            'workflowId' => $s->getWorkflow()?->getId(),
        ], iterator_to_array($statuses instanceof \Traversable ? $statuses : new \ArrayIterator($statuses))));
    }
}
  • Step 2: Commit
git add src/Mcp/Tool/TaskMeta/ListStatusesTool.php
git commit -m "feat(workflow) : MCP list-statuses - param projectId optionnel + category exposée"

Task 28: list-workflows MCP tool

Files:

  • Create: src/Mcp/Tool/Workflow/ListWorkflowsTool.php

  • Step 1: Créer le tool

<?php

declare(strict_types=1);

namespace App\Mcp\Tool\Workflow;

use App\Repository\WorkflowRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

#[McpTool(
    name: 'list-workflows',
    description: 'List all workflows (status templates) with their statuses grouped under each workflow. Each project has one workflow that defines its kanban columns.',
)]
class ListWorkflowsTool
{
    public function __construct(
        private readonly WorkflowRepository $workflowRepository,
        private readonly Security $security,
    ) {}

    public function __invoke(): string
    {
        if (!$this->security->isGranted('ROLE_USER')) {
            throw new AccessDeniedException('Access denied: ROLE_USER required.');
        }

        $workflows = $this->workflowRepository->findBy([], ['position' => 'ASC']);

        return json_encode(array_map(fn ($w) => [
            'id'        => $w->getId(),
            'name'      => $w->getName(),
            'isDefault' => $w->isDefault(),
            'position'  => $w->getPosition(),
            'statuses'  => array_map(fn ($s) => [
                'id'       => $s->getId(),
                'label'    => $s->getLabel(),
                'color'    => $s->getColor(),
                'position' => $s->getPosition(),
                'isFinal'  => $s->getIsFinal(),
                'category' => $s->getCategory()->value,
            ], $w->getStatuses()->toArray()),
        ], $workflows));
    }
}
  • Step 2: Vérifier que la discovery MCP fonctionne
docker exec -t php-lesstime-fpm php bin/console cache:clear
docker exec -t php-lesstime-fpm php bin/console mcp:tools:list 2>&1 | grep -i workflow

Expected: list-workflows apparaît.

(Si la commande mcp:tools:list n'existe pas, tester via un appel HTTP au /_mcp tools/list.)

  • Step 3: Commit
git add src/Mcp/Tool/Workflow/ListWorkflowsTool.php
git commit -m "feat(workflow) : MCP list-workflows tool"

Task 29: switch-project-workflow MCP tool

Files:

  • Create: src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php

  • Step 1: Créer le tool

<?php

declare(strict_types=1);

namespace App\Mcp\Tool\Workflow;

use App\Entity\Project;
use App\State\SwitchProjectWorkflowProcessor;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

#[McpTool(
    name: 'switch-project-workflow',
    description: 'Switch a project to another workflow. mapping must cover every status currently used by the project\'s tasks: keys are source status IDs (string), values are target status IDs in the new workflow (int) or null to send tasks to backlog. Requires ROLE_ADMIN. Returns { migratedTaskCount }.',
)]
class SwitchProjectWorkflowTool
{
    public function __construct(
        private readonly SwitchProjectWorkflowProcessor $processor,
        private readonly EntityManagerInterface $entityManager,
        private readonly Security $security,
    ) {}

    /**
     * @param array<string, int|null> $mapping
     */
    public function __invoke(int $projectId, int $workflowId, array $mapping): string
    {
        if (!$this->security->isGranted('ROLE_ADMIN')) {
            throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
        }

        $project = $this->entityManager->find(Project::class, $projectId);
        if (!$project) {
            return json_encode(['error' => 'Project not found.']);
        }

        // On invoque le processor en lui fournissant un context Request fake
        $fakeRequest = Request::create('', 'POST', [], [], [], [], json_encode([
            'workflowId' => $workflowId,
            'mapping'    => $mapping,
        ]));

        try {
            $result = $this->processor->process(
                $project,
                operation: new \ApiPlatform\Metadata\Post(name: 'switch_workflow'),
                uriVariables: ['id' => $projectId],
                context: ['request' => $fakeRequest],
            );
        } catch (\Throwable $e) {
            return json_encode(['error' => $e->getMessage()]);
        }

        return json_encode([
            'migratedTaskCount' => $result->migratedTaskCount,
            'projectId'         => $result->projectId,
            'workflowId'        => $result->workflowId,
        ]);
    }
}
  • Step 2: Test smoke via curl MCP
curl -sS -X POST http://localhost:8082/_mcp \
  -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"switch-project-workflow","arguments":{"projectId":1,"workflowId":1,"mapping":{}}}}'

Expected: réponse JSON avec error si projet déjà sur ce workflow ou si mapping incomplet, sinon migratedTaskCount.

  • Step 3: Mettre à jour les descriptions create-task / update-task

Dans src/Mcp/Tool/Task/CreateTaskTool.php et UpdateTaskTool.php (à localiser via grep -rln "create-task" src/Mcp), ajouter une ligne dans la description :

"The status parameter must reference a status that belongs to the target project's workflow — otherwise the call is rejected with a validation error."

  • Step 4: Commit
git add src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php src/Mcp/Tool/Task/
git commit -m "feat(workflow) : MCP switch-project-workflow + maj descriptions create/update-task"

Phase 11 — Vérification finale + ménage

Task 30: Vérification end-to-end + nettoyage

Files: (aucun)

  • Step 1: Tests backend
make test

Expected: tous verts (3 nouveaux fichiers de test au minimum).

  • Step 2: Code style
make php-cs-fixer-allow-risky
  • Step 3: Cache + schéma
make cache-clear
docker exec -t php-lesstime-fpm php bin/console doctrine:schema:validate

Expected: synced + correct.

  • Step 4: Smoke test UI complet

Scénario manuel à dérouler dans le navigateur :

  1. Logger admin → /admin → onglet Workflows visible.
  2. Créer un workflow "DevKanban" avec 4 statuts (Backlog/todo, In Dev/in_progress, Review/review, Done/done).
  3. Ouvrir un projet existant → ProjectDrawer → bouton "Changer de workflow" → choisir DevKanban → mapping pré-rempli par catégorie → confirmer.
  4. Vérifier que le kanban du projet affiche maintenant les 4 colonnes DevKanban.
  5. Aller sur /my-tasks → 5 colonnes par catégorie, tâches du projet switché visibles avec leur nouveau badge statut.
  6. Tenter de supprimer le workflow Standard depuis l'admin → erreur 409 avec message clair.
  7. Logger client / user normal → vérifier qu'ils ne voient PAS l'onglet Workflows (déjà couvert par le middleware admin).
  • Step 5: Vérifier le MCP

Via Claude Code avec le MCP lesstime connecté :

  • Appeler list-workflows → liste les 2 workflows avec statuts.

  • Appeler list-statuses sans paramètre → tous statuts toutes workflows.

  • Appeler list-statuses avec projectId=<id du projet switché> → uniquement les statuts DevKanban.

  • Appeler create-task avec un status d'un autre workflow → erreur 422 attendue.

  • Step 6: Bump version + commit final

# Mettre à jour config/version.yaml : app.version → v0.4.0 (feature majeure)
git add config/version.yaml
git commit -m "chore : bump version to v0.4.0"
  • Step 7: Tag + push
git tag v0.4.0
git push origin feat/project-workflows --tags
  • Step 8: PR vers develop
gh pr create --base develop --head feat/project-workflows \
  --title "feat(workflow) : workflows de statuts par projet (kanban custom)" \
  --body "$(cat <<'EOF'
## Summary
- Chaque projet a désormais son propre workflow (jeu de statuts kanban réutilisable défini en admin)
- Vue \`my-tasks\` regroupée par catégorie canonique (5 colonnes : todo/in_progress/blocked/review/done)
- Endpoint \`POST /api/projects/{id}/switch-workflow\` avec mapping source→cible transactionnel
- MCP : \`list-workflows\` et \`switch-project-workflow\` ajoutés, \`list-statuses\` gagne un \`projectId\` optionnel
- AdminStatusTab fusionné dans AdminWorkflowTab

Spec : \`docs/superpowers/specs/2026-05-19-project-workflows-design.md\`

## Test plan
- [ ] Migrations passent à blanc sur prod (label "A faire" mappé en todo ; toute dérive fait échouer M2 — vérifier la prod avant)
- [ ] Création workflow DevKanban + switch projet existant + mapping pré-rempli par catégorie
- [ ] my-tasks affiche 5 colonnes par catégorie
- [ ] Suppression workflow lié → 409
- [ ] MCP : list-workflows, list-statuses?projectId, switch-project-workflow, create-task rejet inter-workflow

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"

Self-review checklist (à exécuter par l'agent à la fin)

  • Tous les fichiers de la table "Fichiers créés / modifiés" ont une tâche correspondante.
  • Pas de TODO/TBD/placeholder dans le plan.
  • Les méthodes référencées tard (ex : WorkflowDeleteProcessor cité dans Task 2 et implémenté Task 12) sont bien cohérentes en signature.
  • Les noms d'API IRI utilisés (/api/workflows, /api/task_statuses, /api/projects/{id}/switch-workflow) sont cohérents partout.
  • La modal Switch envoie bien mapping avec clés string (cf. tests fonctionnels et processor).
  • Les groupes Symfony de sérialisation sur Workflow/TaskStatus/Project se mailent (workflow:read embarque statuses, project:read embarque workflow avec ses statuses).
  • Le bump version v0.4.0 est cohérent avec la convention (feature majeure → bump mineur, cf. v0.3.x actuels).