3037 lines
95 KiB
Markdown
3037 lines
95 KiB
Markdown
# 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
|
|
<?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**
|
|
|
|
```bash
|
|
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
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/Entity/Workflow.php src/Repository/WorkflowRepository.php
|
|
git commit -m "feat(workflow) : ajoute l'entité Workflow et son repository"
|
|
```
|
|
|
|
> Note : `WorkflowDeleteProcessor` est référencé ici mais sera créé en Task 14. Le code compile mais l'opération Delete restera non fonctionnelle jusque-là — c'est OK, on n'écrira pas dans la table avant Task 9 (migrations).
|
|
|
|
---
|
|
|
|
## Task 3: Modifications TaskStatus (workflow + category)
|
|
|
|
**Files:**
|
|
- Modify: `src/Entity/TaskStatus.php`
|
|
|
|
- [ ] **Step 1: Ajouter les imports et propriétés**
|
|
|
|
Remplacer le contenu de `src/Entity/TaskStatus.php` par :
|
|
|
|
```php
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/Entity/TaskStatus.php
|
|
git commit -m "feat(workflow) : ajoute workflow et category sur TaskStatus"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Modifications Project (workflow embarqué)
|
|
|
|
**Files:**
|
|
- Modify: `src/Entity/Project.php`
|
|
|
|
- [ ] **Step 1: Ajouter la relation Workflow**
|
|
|
|
Dans `src/Entity/Project.php`, ajouter après l'import `Doctrine\ORM\Mapping as ORM;` :
|
|
|
|
```php
|
|
use Symfony\Component\Validator\Constraints as Assert;
|
|
```
|
|
|
|
(s'il n'est pas déjà importé)
|
|
|
|
Puis ajouter la propriété après `private ?Client $client = null;` (vers ligne 70) :
|
|
|
|
```php
|
|
#[ORM\ManyToOne(targetEntity: Workflow::class)]
|
|
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
|
#[Groups(['project:read', 'project:write', 'task:read'])]
|
|
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
|
|
private ?Workflow $workflow = null;
|
|
```
|
|
|
|
Puis ajouter les accesseurs à la fin de la classe (avant `getTaskCount`) :
|
|
|
|
```php
|
|
public function getWorkflow(): ?Workflow
|
|
{
|
|
return $this->workflow;
|
|
}
|
|
|
|
public function setWorkflow(Workflow $workflow): static
|
|
{
|
|
$this->workflow = $workflow;
|
|
|
|
return $this;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/Project.php
|
|
git commit -m "feat(workflow) : ajoute workflow requis sur Project (RESTRICT)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Migration M1 — table workflow + insert Standard
|
|
|
|
**Files:**
|
|
- Create: `migrations/Version<timestamp>_create_workflow.php`
|
|
|
|
- [ ] **Step 1: Générer le squelette**
|
|
|
|
```bash
|
|
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate
|
|
```
|
|
|
|
Renommer le fichier généré en `Version<timestamp>_create_workflow.php` pour clarté (Doctrine n'impose pas le nom).
|
|
|
|
- [ ] **Step 2: Remplir le up/down**
|
|
|
|
```php
|
|
<?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**
|
|
|
|
```bash
|
|
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:list
|
|
```
|
|
|
|
Expected: la nouvelle migration apparaît "Not migrated".
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add migrations/Version<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**
|
|
|
|
```bash
|
|
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate
|
|
```
|
|
|
|
```php
|
|
<?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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate
|
|
```
|
|
|
|
```php
|
|
<?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**
|
|
|
|
```bash
|
|
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 :
|
|
|
|
```php
|
|
use App\Entity\Workflow;
|
|
use App\Enum\StatusCategory;
|
|
```
|
|
|
|
- [ ] **Step 2: Créer le workflow Standard avant les statuts**
|
|
|
|
Localiser le bloc `// Task Statuses (global)` (vers ligne 122). Remplacer le bloc par :
|
|
|
|
```php
|
|
// Workflow par défaut
|
|
$standardWorkflow = new Workflow();
|
|
$standardWorkflow->setName('Standard');
|
|
$standardWorkflow->setIsDefault(true);
|
|
$standardWorkflow->setPosition(0);
|
|
$manager->persist($standardWorkflow);
|
|
|
|
// Task Statuses (rattachés au workflow Standard)
|
|
$defaultStatuses = [
|
|
['A faire', '#222783', 0, StatusCategory::Todo, false],
|
|
['En cours', '#4A90D9', 1, StatusCategory::InProgress, false],
|
|
['Bloqué', '#C62828', 2, StatusCategory::Blocked, false],
|
|
['En attente de validation', '#FF8F00', 3, StatusCategory::Review, false],
|
|
['Terminé', '#26A69A', 4, StatusCategory::Done, true],
|
|
];
|
|
|
|
$statusObjects = [];
|
|
foreach ($defaultStatuses as [$label, $color, $position, $category, $isFinal]) {
|
|
$status = new TaskStatus();
|
|
$status->setLabel($label);
|
|
$status->setColor($color);
|
|
$status->setPosition($position);
|
|
$status->setCategory($category);
|
|
$status->setIsFinal($isFinal);
|
|
$standardWorkflow->addStatus($status);
|
|
$manager->persist($status);
|
|
$statusObjects[$label] = $status;
|
|
}
|
|
|
|
$statusTodo = $statusObjects['A faire'];
|
|
$statusInProgress = $statusObjects['En cours'];
|
|
$statusBlocked = $statusObjects['Bloqué'];
|
|
$statusReview = $statusObjects['En attente de validation'];
|
|
$statusDone = $statusObjects['Terminé'];
|
|
```
|
|
|
|
- [ ] **Step 3: Assigner le workflow Standard à tous les projets fixtures**
|
|
|
|
Localiser chaque création de `Project` (recherche `new Project()`). Pour chacun, ajouter juste après `setColor` :
|
|
|
|
```php
|
|
$project<X>->setWorkflow($standardWorkflow);
|
|
```
|
|
|
|
(remplacer `<X>` par le nom de variable du projet : `$projectClient`, `$projectInterne`, etc.)
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/DataFixtures/AppFixtures.php
|
|
git commit -m "feat(workflow) : fixtures - workflow Standard + statuts catégorisés + projets attachés"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Listener unique-default-workflow
|
|
|
|
**Files:**
|
|
- Create: `src/EventListener/UniqueDefaultWorkflowListener.php`
|
|
|
|
- [ ] **Step 1: Écrire le listener**
|
|
|
|
```php
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/EventListener/UniqueDefaultWorkflowListener.php
|
|
git commit -m "feat(workflow) : listener garantissant un seul workflow isDefault=true"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Appliquer migrations + fixtures + smoke test
|
|
|
|
**Files:** (aucun fichier source — vérification runtime)
|
|
|
|
- [ ] **Step 1: Lancer les migrations**
|
|
|
|
```bash
|
|
make migration-migrate
|
|
```
|
|
|
|
Expected: les 3 migrations passent vert.
|
|
|
|
- [ ] **Step 2: Vérifier le schéma**
|
|
|
|
```bash
|
|
docker exec -t php-lesstime-fpm php bin/console doctrine:schema:validate
|
|
```
|
|
|
|
Expected: "The mapping files are correct." et "The database schema is in sync with the mapping files."
|
|
|
|
- [ ] **Step 3: Recharger les fixtures**
|
|
|
|
```bash
|
|
make fixtures
|
|
```
|
|
|
|
Expected: pas d'erreur.
|
|
|
|
- [ ] **Step 4: Vérifier le contenu via psql**
|
|
|
|
```bash
|
|
docker exec -t php-lesstime-fpm bash -c "PGPASSWORD=malio psql -h host.docker.internal -p 5432 -U malio -d lesstime -c 'SELECT id, name, is_default FROM workflow'"
|
|
docker exec -t php-lesstime-fpm bash -c "PGPASSWORD=malio psql -h host.docker.internal -p 5432 -U malio -d lesstime -c 'SELECT id, label, category, workflow_id FROM task_status ORDER BY position'"
|
|
docker exec -t php-lesstime-fpm bash -c "PGPASSWORD=malio psql -h host.docker.internal -p 5432 -U malio -d lesstime -c 'SELECT id, code, workflow_id FROM project'"
|
|
```
|
|
|
|
Expected: workflow Standard existe, 5 statuts avec category remplie, tous les projets ont `workflow_id` non-null.
|
|
|
|
- [ ] **Step 5: Smoke test API**
|
|
|
|
```bash
|
|
docker exec -t php-lesstime-fpm curl -sS http://localhost/api/workflows -H "Cookie: BEARER=$(echo)" | head -50
|
|
```
|
|
|
|
(Test alternatif via UI : se logger en admin et appeler `/api/workflows` depuis devtools)
|
|
|
|
---
|
|
|
|
## Phase 2 — Sérialisation, validation cross-entity, Delete protection
|
|
|
|
## Task 11: Validation cross-entity sur Task (status.workflow == project.workflow)
|
|
|
|
**Files:**
|
|
- Modify: `src/Entity/Task.php`
|
|
|
|
- [ ] **Step 1: Ajouter la validation**
|
|
|
|
Dans `src/Entity/Task.php`, ajouter à la fin de la classe (après `validateCollaborators`) :
|
|
|
|
```php
|
|
#[Assert\Callback]
|
|
public function validateStatusBelongsToProjectWorkflow(ExecutionContextInterface $context): void
|
|
{
|
|
if (null === $this->status || null === $this->project) {
|
|
return;
|
|
}
|
|
|
|
$projectWorkflow = $this->project->getWorkflow();
|
|
$statusWorkflow = $this->status->getWorkflow();
|
|
|
|
if (null === $projectWorkflow || null === $statusWorkflow) {
|
|
return; // états transitoires
|
|
}
|
|
|
|
if ($projectWorkflow->getId() !== $statusWorkflow->getId()) {
|
|
$context->buildViolation('Status does not belong to this project\'s workflow.')
|
|
->atPath('status')
|
|
->addViolation()
|
|
;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/Task.php
|
|
git commit -m "feat(workflow) : valide que task.status appartient au workflow du projet"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: WorkflowDeleteProcessor (409 si projets liés)
|
|
|
|
**Files:**
|
|
- Create: `src/State/WorkflowDeleteProcessor.php`
|
|
|
|
- [ ] **Step 1: Test fonctionnel d'abord (TDD)**
|
|
|
|
Créer `tests/Functional/WorkflowDeleteProtectionTest.php` :
|
|
|
|
```php
|
|
<?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)**
|
|
|
|
```bash
|
|
make test -- --filter WorkflowDeleteProtectionTest
|
|
```
|
|
|
|
Expected: échec (le processor n'existe pas encore).
|
|
|
|
- [ ] **Step 3: Implémenter le processor**
|
|
|
|
```php
|
|
<?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)**
|
|
|
|
```bash
|
|
make test -- --filter WorkflowDeleteProtectionTest
|
|
```
|
|
|
|
Expected: green.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/State/WorkflowDeleteProcessor.php tests/Functional/WorkflowDeleteProtectionTest.php
|
|
git commit -m "feat(workflow) : protège la suppression d'un workflow lié à des projets (409)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Test fonctionnel — validation cross-entity Task
|
|
|
|
**Files:**
|
|
- Create: `tests/Functional/TaskWorkflowValidationTest.php`
|
|
|
|
- [ ] **Step 1: Écrire le test**
|
|
|
|
```php
|
|
<?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**
|
|
|
|
```bash
|
|
make test -- --filter TaskWorkflowValidationTest
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/Functional/TaskWorkflowValidationTest.php
|
|
git commit -m "test(workflow) : valide le rejet d'un status hors workflow projet"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3 — Endpoint switch-workflow
|
|
|
|
## Task 14: SwitchProjectWorkflowProcessor
|
|
|
|
**Files:**
|
|
- Create: `src/State/SwitchProjectWorkflowProcessor.php`
|
|
|
|
- [ ] **Step 1: Test fonctionnel (TDD)**
|
|
|
|
Créer `tests/Functional/SwitchProjectWorkflowTest.php` :
|
|
|
|
```php
|
|
<?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)**
|
|
|
|
```bash
|
|
make test -- --filter SwitchProjectWorkflowTest
|
|
```
|
|
|
|
- [ ] **Step 3: Créer le DTO de sortie**
|
|
|
|
`src/ApiResource/SwitchWorkflowOutput.php` :
|
|
|
|
```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
|
|
<?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 :
|
|
|
|
```php
|
|
use ApiPlatform\Metadata\Link;
|
|
use App\State\SwitchProjectWorkflowProcessor;
|
|
```
|
|
|
|
Modifier la liste des `operations` de `#[ApiResource]` en ajoutant après le `Delete` :
|
|
|
|
```php
|
|
new Post(
|
|
uriTemplate: '/projects/{id}/switch-workflow',
|
|
uriVariables: ['id' => new Link(fromClass: Project::class)],
|
|
security: "is_granted('ROLE_ADMIN')",
|
|
input: false,
|
|
output: SwitchWorkflowOutput::class,
|
|
normalizationContext: ['groups' => ['switch_workflow:read']],
|
|
processor: SwitchProjectWorkflowProcessor::class,
|
|
read: true,
|
|
deserialize: false,
|
|
validate: false,
|
|
write: false,
|
|
name: 'switch_workflow',
|
|
),
|
|
```
|
|
|
|
> `input: false` désactive la deserialization (on lit le body brut dans le processor). `output: SwitchWorkflowOutput::class` indique à API Platform que la réponse a la forme du DTO — la sérialisation utilise le groupe `switch_workflow:read`. `read: true` charge l'entité Project depuis l'id de l'URL.
|
|
|
|
- [ ] **Step 2: Lancer les tests du switch (PASS attendu)**
|
|
|
|
```bash
|
|
make test -- --filter SwitchProjectWorkflowTest
|
|
```
|
|
|
|
Expected: 2 tests verts.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/Project.php src/State/SwitchProjectWorkflowProcessor.php src/ApiResource/SwitchWorkflowOutput.php tests/Functional/SwitchProjectWorkflowTest.php
|
|
git commit -m "feat(workflow) : endpoint POST /projects/{id}/switch-workflow + processor transactionnel"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4 — Frontend : DTOs + service workflows
|
|
|
|
## Task 16: DTO Workflow + extension TaskStatus + Project
|
|
|
|
**Files:**
|
|
- Create: `frontend/services/dto/workflow.ts`
|
|
- Modify: `frontend/services/dto/task-status.ts`
|
|
- Modify: `frontend/services/dto/project.ts`
|
|
|
|
- [ ] **Step 1: Créer workflow.ts**
|
|
|
|
```ts
|
|
import type { TaskStatus, TaskStatusWrite } from './task-status'
|
|
|
|
export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'
|
|
|
|
export const STATUS_CATEGORY_LABEL: Record<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**
|
|
|
|
```ts
|
|
import type { StatusCategory } from './workflow'
|
|
|
|
export type TaskStatus = {
|
|
id: number
|
|
'@id'?: string
|
|
label: string
|
|
color: string
|
|
position: number
|
|
isFinal: boolean
|
|
category: StatusCategory
|
|
workflow?: { '@id': string, id: number } | string
|
|
}
|
|
|
|
export type TaskStatusWrite = {
|
|
label: string
|
|
color: string
|
|
position: number
|
|
isFinal: boolean
|
|
category: StatusCategory
|
|
workflow?: string // IRI : "/api/workflows/1"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Étendre project.ts**
|
|
|
|
Ajouter l'import :
|
|
|
|
```ts
|
|
import type { Workflow } from './workflow'
|
|
```
|
|
|
|
Étendre les types :
|
|
|
|
```ts
|
|
export type Project = {
|
|
id: number
|
|
'@id'?: string
|
|
code: string
|
|
name: string
|
|
description: string | null
|
|
color: string
|
|
client: Client | null
|
|
workflow: Workflow
|
|
giteaOwner: string | null
|
|
giteaRepo: string | null
|
|
bookstackShelfId: number | null
|
|
bookstackShelfName: string | null
|
|
archived: boolean
|
|
taskCount: number
|
|
}
|
|
|
|
export type ProjectWrite = {
|
|
code?: string
|
|
name: string
|
|
description: string | null
|
|
color: string
|
|
client: string | null
|
|
workflow?: string // IRI : "/api/workflows/1"
|
|
giteaOwner?: string | null
|
|
giteaRepo?: string | null
|
|
bookstackShelfId?: number | null
|
|
bookstackShelfName?: string | null
|
|
archived?: boolean
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/services/dto/workflow.ts frontend/services/dto/task-status.ts frontend/services/dto/project.ts
|
|
git commit -m "feat(workflow) : DTOs front Workflow + category sur TaskStatus + workflow embarqué sur Project"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 17: Service workflows.ts
|
|
|
|
**Files:**
|
|
- Create: `frontend/services/workflows.ts`
|
|
|
|
- [ ] **Step 1: Écrire le service**
|
|
|
|
```ts
|
|
import type { Workflow, WorkflowWrite } from './dto/workflow'
|
|
import type { HydraCollection } from '~/utils/api'
|
|
import { extractHydraMembers } from '~/utils/api'
|
|
|
|
type SwitchPayload = {
|
|
workflowId: number
|
|
mapping: Record<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 :
|
|
|
|
```json
|
|
"workflows": {
|
|
"title": "Workflows",
|
|
"addWorkflow": "Ajouter un workflow",
|
|
"editWorkflow": "Modifier le workflow",
|
|
"name": "Nom",
|
|
"isDefault": "Workflow par défaut",
|
|
"statuses": "Statuts",
|
|
"addStatus": "Ajouter un statut",
|
|
"category": "Catégorie",
|
|
"created": "Workflow créé",
|
|
"updated": "Workflow mis à jour",
|
|
"deleted": "Workflow supprimé",
|
|
"switched": "Workflow du projet changé",
|
|
"switchTitle": "Changer de workflow",
|
|
"switchTargetLabel": "Nouveau workflow",
|
|
"switchMappingTitle": "Mapping des statuts",
|
|
"switchSourceCol": "Statut actuel",
|
|
"switchTargetCol": "Statut cible",
|
|
"switchTaskCountCol": "Tâches",
|
|
"switchToBacklog": "Mapper vers le backlog",
|
|
"switchConfirm": "Confirmer la migration",
|
|
"switchSummary": "{count} tâche(s) migrée(s), projet sur workflow « {name} »",
|
|
"deleteUsedBy": "Workflow utilisé par {count} projet(s) — impossible de supprimer.",
|
|
"categories": {
|
|
"todo": "À faire",
|
|
"in_progress": "En cours",
|
|
"blocked": "Bloqué",
|
|
"review": "En validation",
|
|
"done": "Terminé"
|
|
}
|
|
}
|
|
```
|
|
|
|
Dans `frontend/i18n/locales/en.json`, ajouter l'équivalent anglais (mêmes clés, libellés anglais).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/services/workflows.ts frontend/i18n/locales/fr.json frontend/i18n/locales/en.json
|
|
git commit -m "feat(workflow) : service front workflows + i18n"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5 — Admin UI
|
|
|
|
## Task 18: WorkflowDrawer (création/édition workflow + statuts inline)
|
|
|
|
**Files:**
|
|
- Create: `frontend/components/admin/WorkflowDrawer.vue`
|
|
|
|
- [ ] **Step 1: Écrire le composant**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
git add frontend/components/admin/WorkflowDrawer.vue
|
|
git commit -m "feat(workflow) : WorkflowDrawer - création/édition workflow et statuts inline"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 19: AdminWorkflowTab
|
|
|
|
**Files:**
|
|
- Create: `frontend/components/admin/AdminWorkflowTab.vue`
|
|
|
|
- [ ] **Step 1: Écrire le composant**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
git add frontend/components/admin/AdminWorkflowTab.vue
|
|
git commit -m "feat(workflow) : AdminWorkflowTab - liste et gestion des workflows"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 20: Remplacer l'onglet Statuts par l'onglet Workflows
|
|
|
|
**Files:**
|
|
- Modify: `frontend/pages/admin.vue`
|
|
- Delete: `frontend/components/admin/AdminStatusTab.vue`
|
|
|
|
- [ ] **Step 1: Mettre à jour admin.vue**
|
|
|
|
Dans `frontend/pages/admin.vue`, remplacer la ligne `<AdminStatusTab v-if="activeTab === 'statuses'" />` par :
|
|
|
|
```vue
|
|
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
|
|
```
|
|
|
|
Et dans la liste `tabs`, remplacer `{ key: 'statuses', label: 'Statuts' }` par :
|
|
|
|
```ts
|
|
{ key: 'workflows', label: 'Workflows' },
|
|
```
|
|
|
|
- [ ] **Step 2: Supprimer le fichier obsolète**
|
|
|
|
```bash
|
|
git rm frontend/components/admin/AdminStatusTab.vue
|
|
```
|
|
|
|
- [ ] **Step 3: Smoke test**
|
|
|
|
```bash
|
|
make dev-nuxt
|
|
```
|
|
|
|
Naviguer sur `http://localhost:3002/admin`, vérifier que l'onglet "Workflows" remplace "Statuts", et que la création/édition d'un workflow fonctionne.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/pages/admin.vue
|
|
git commit -m "feat(workflow) : remplace l'onglet Statuts par Workflows en admin"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 6 — Kanban projet + archives projet
|
|
|
|
## Task 21: Adapter projects/[id]/index.vue (kanban basé sur workflow du projet)
|
|
|
|
**Files:**
|
|
- Modify: `frontend/pages/projects/[id]/index.vue`
|
|
|
|
- [ ] **Step 1: Lire la page actuelle pour repérer l'usage de statuses**
|
|
|
|
```bash
|
|
grep -n "statuses\|statusService" frontend/pages/projects/[id]/index.vue
|
|
```
|
|
|
|
- [ ] **Step 2: Modifier le chargement**
|
|
|
|
Remplacer l'appel `statusService.getAll()` par l'utilisation de `project.workflow.statuses` chargé via `projectService.getOne(id)`. Précisément :
|
|
|
|
- Supprimer l'import `useTaskStatusService` et son usage.
|
|
- Après chargement du `project` courant, faire :
|
|
|
|
```ts
|
|
const statuses = computed<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**
|
|
|
|
```bash
|
|
git add frontend/pages/projects/[id]/index.vue
|
|
git commit -m "feat(workflow) : kanban projet basé sur les statuts du workflow du projet"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 22: Adapter projects/[id]/archives.vue
|
|
|
|
**Files:**
|
|
- Modify: `frontend/pages/projects/[id]/archives.vue`
|
|
|
|
- [ ] **Step 1: Filtre statut limité au workflow du projet**
|
|
|
|
Localiser le filtre statut (souvent un `MalioSelect` basé sur `statuses`). Le brancher sur `project.workflow.statuses` au lieu de la collection globale (même pattern que Task 21).
|
|
|
|
- [ ] **Step 2: Smoke test**
|
|
|
|
Naviguer sur `/projects/<id>/archives`, vérifier que le dropdown statut n'affiche que les statuts du workflow du projet.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/pages/projects/[id]/archives.vue
|
|
git commit -m "feat(workflow) : archives projet - filtre statut limité au workflow du projet"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 7 — my-tasks : kanban groupé par catégorie
|
|
|
|
## Task 23: Refactor my-tasks.vue en kanban par catégorie + badge statut sur TaskCard
|
|
|
|
**Files:**
|
|
- Modify: `frontend/pages/my-tasks.vue`
|
|
- Modify: `frontend/components/task/TaskCard.vue`
|
|
|
|
- [ ] **Step 1: Ajouter une prop `showStatusBadge` à TaskCard**
|
|
|
|
Dans `frontend/components/task/TaskCard.vue`, ajouter dans les props :
|
|
|
|
```ts
|
|
const props = defineProps<{
|
|
task: Task
|
|
showProjectColor?: boolean
|
|
showStatusBadge?: boolean
|
|
}>()
|
|
```
|
|
|
|
Et dans le template, là où l'on affiche déjà des badges (groupe/priorité), ajouter conditionnellement :
|
|
|
|
```vue
|
|
<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 :
|
|
|
|
```ts
|
|
import type { StatusCategory } from '~/services/dto/workflow'
|
|
import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow'
|
|
|
|
const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
|
|
|
|
function tasksByCategory(category: StatusCategory): Task[] {
|
|
return tasks.value.filter(t => t.status?.category === category)
|
|
}
|
|
|
|
// Backlog = tâches sans statut, identique à avant
|
|
const backlogTasks = computed(() => tasks.value.filter(t => !t.status))
|
|
```
|
|
|
|
Supprimer `sortedStatuses` et `tasksByStatus`.
|
|
|
|
Dans le template, remplacer le bloc "Kanban View" (`<div v-if="viewMode === 'kanban'">`) par :
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
git add frontend/pages/my-tasks.vue frontend/components/task/TaskCard.vue
|
|
git commit -m "feat(workflow) : my-tasks - kanban groupé par catégorie avec badge statut"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 8 — Modal Switch + intégration ProjectDrawer
|
|
|
|
## Task 24: ProjectWorkflowSwitchModal
|
|
|
|
**Files:**
|
|
- Create: `frontend/components/project/ProjectWorkflowSwitchModal.vue`
|
|
|
|
- [ ] **Step 1: Écrire le composant**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
git add frontend/components/project/ProjectWorkflowSwitchModal.vue
|
|
git commit -m "feat(workflow) : ProjectWorkflowSwitchModal - mapping source→cible avec pré-remplissage par catégorie"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 25: Intégrer la modal dans ProjectDrawer
|
|
|
|
**Files:**
|
|
- Modify: `frontend/components/project/ProjectDrawer.vue`
|
|
|
|
- [ ] **Step 1: Afficher le workflow + bouton "Changer"**
|
|
|
|
Dans `ProjectDrawer.vue`, ajouter une section "Workflow" sous les autres champs (ou à l'endroit qui convient stylistiquement) :
|
|
|
|
```vue
|
|
<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>` :
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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 :
|
|
|
|
```ts
|
|
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` :
|
|
|
|
```vue
|
|
<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 :
|
|
|
|
```vue
|
|
: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**
|
|
|
|
```bash
|
|
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
|
|
<?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**
|
|
|
|
```bash
|
|
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
|
|
<?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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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
|
|
<?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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
make test
|
|
```
|
|
|
|
Expected: tous verts (3 nouveaux fichiers de test au minimum).
|
|
|
|
- [ ] **Step 2: Code style**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
```
|
|
|
|
- [ ] **Step 3: Cache + schéma**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
# Mettre à jour config/version.yaml : app.version → v0.4.0 (feature majeure)
|
|
```
|
|
|
|
```bash
|
|
git add config/version.yaml
|
|
git commit -m "chore : bump version to v0.4.0"
|
|
```
|
|
|
|
- [ ] **Step 7: Tag + push**
|
|
|
|
```bash
|
|
git tag v0.4.0
|
|
git push origin feat/project-workflows --tags
|
|
```
|
|
|
|
- [ ] **Step 8: PR vers develop**
|
|
|
|
```bash
|
|
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).
|