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 :
WorkflowDeleteProcessorest 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: falsedésactive la deserialization (on lit le body brut dans le processor).output: SwitchWorkflowOutput::classindique à API Platform que la réponse a la forme du DTO — la sérialisation utilise le groupeswitch_workflow:read.read: truecharge 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
isDefaultutilise la slotcell-isDefault(cf. DataTable patterns existants commecell-colordans AdminStatusTab). Si le typage deDataTableColumn.labeln'accepte pas une fonction, remplacer par une string statiquet('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
useTaskStatusServiceet son usage. - Après chargement du
projectcourant, 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[]>pourstatuses, remplacer par uncomputedqui dérive deproject.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/onDropBackloget les attributs@dragenter/@dragleave/@dropdu 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
selectedTaskset 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 :
- Logger admin →
/admin→ onglet Workflows visible. - Créer un workflow "DevKanban" avec 4 statuts (Backlog/todo, In Dev/in_progress, Review/review, Done/done).
- Ouvrir un projet existant → ProjectDrawer → bouton "Changer de workflow" → choisir DevKanban → mapping pré-rempli par catégorie → confirmer.
- Vérifier que le kanban du projet affiche maintenant les 4 colonnes DevKanban.
- Aller sur
/my-tasks→ 5 colonnes par catégorie, tâches du projet switché visibles avec leur nouveau badge statut. - Tenter de supprimer le workflow Standard depuis l'admin → erreur 409 avec message clair.
- 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-statusessans paramètre → tous statuts toutes workflows. -
Appeler
list-statusesavecprojectId=<id du projet switché>→ uniquement les statuts DevKanban. -
Appeler
create-taskavec unstatusd'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 :
WorkflowDeleteProcessorcité 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
Switchenvoie bienmappingavec 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).