diff --git a/.gitignore b/.gitignore index 9e182e1..8ff156c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,9 @@ ###> docker local ### infra/dev/.env.docker.local ###< docker local ### + +###> local db dumps ### +*.sql.gz +*.sql.gz:Zone.Identifier +REVIEW.md +###< local db dumps ### diff --git a/config/version.yaml b/config/version.yaml index 26d9560..9409b68 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.3.34' + app.version: '0.4.0' diff --git a/docs/superpowers/plans/2026-05-19-project-workflows.md b/docs/superpowers/plans/2026-05-19-project-workflows.md new file mode 100644 index 0000000..2b5a494 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-project-workflows.md @@ -0,0 +1,3036 @@ +# Workflows de statuts par projet — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Permettre à chaque projet d'avoir son propre kanban (workflow réutilisable défini en admin), tout en gardant les vues transverses (`my-tasks`, archives, time tracking) cohérentes. + +**Architecture:** Nouvelle entité `Workflow` (templates), chaque `TaskStatus` appartient à un workflow et porte une `category` enum (5 valeurs canoniques : todo, in_progress, blocked, review, done). `Project.workflow_id` est requis (FK RESTRICT). Vue `my-tasks` regroupe par catégorie ; vue `projects/[id]` utilise les statuts du workflow du projet. Endpoint dédié `POST /api/projects/{id}/switch-workflow` avec mapping source→cible exécuté en transaction. UI admin fusionne workflows et statuts (un seul onglet). MCP gagne `list-workflows` et `switch-project-workflow`. + +**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3 Composition API, TypeScript, Tailwind, `@malio/layer-ui`, MCP SDK PHP. + +**Spec:** `docs/superpowers/specs/2026-05-19-project-workflows-design.md` + +--- + +## Fichiers créés / modifiés + +### Backend +| Fichier | Action | Responsabilité | +|---|---|---| +| `src/Enum/StatusCategory.php` | Create | Enum PHP backed des 5 catégories | +| `src/Entity/Workflow.php` | Create | Entité Workflow (ApiResource ROLE_ADMIN pour Post/Patch/Delete) | +| `src/Repository/WorkflowRepository.php` | Create | Repository standard + `findDefault()` | +| `src/Entity/TaskStatus.php` | Modify | Ajout `workflow` (ManyToOne), `category` (enum), groupes sérialisation | +| `src/Entity/Project.php` | Modify | Ajout `workflow` (ManyToOne, requise), embarqué en sérialisation | +| `src/Entity/Task.php` | Modify | Ajout `Assert\Callback` validant `status.workflow === project.workflow` | +| `src/EventListener/UniqueDefaultWorkflowListener.php` | Create | Listener Doctrine garantissant un seul `isDefault=true` | +| `src/ApiResource/SwitchWorkflowOutput.php` | Create | DTO de réponse de l'endpoint switch (project IRI + migratedTaskCount) | +| `src/State/SwitchProjectWorkflowProcessor.php` | Create | Processor de l'endpoint switch (transaction + mapping) | +| `src/State/WorkflowDeleteProcessor.php` | Create | Processor Delete renvoyant 409 si projets liés | +| `migrations/Version_create_workflow.php` | Create | Migration M1 | +| `migrations/Version_add_workflow_to_task_status.php` | Create | Migration M2 (backfill + NOT NULL + échec si label inconnu) | +| `migrations/Version_add_workflow_to_project.php` | Create | Migration M3 | +| `src/DataFixtures/AppFixtures.php` | Modify | Création du workflow Standard + assignation aux projets fixtures | +| `src/Mcp/Tool/TaskMeta/ListStatusesTool.php` | Modify | Param optionnel `projectId` | +| `src/Mcp/Tool/Workflow/ListWorkflowsTool.php` | Create | Nouveau tool MCP | +| `src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php` | Create | Nouveau tool MCP (ROLE_ADMIN) | +| `tests/Functional/SwitchProjectWorkflowTest.php` | Create | Tests fonctionnels endpoint switch | +| `tests/Functional/TaskWorkflowValidationTest.php` | Create | Tests cross-entity validation | +| `tests/Functional/WorkflowDeleteProtectionTest.php` | Create | Tests 409 sur Delete workflow lié | + +### Frontend +| Fichier | Action | Responsabilité | +|---|---|---| +| `frontend/services/dto/workflow.ts` | Create | Types TS Workflow + WorkflowWrite + StatusCategory | +| `frontend/services/workflows.ts` | Create | Service API CRUD + switch | +| `frontend/services/dto/task-status.ts` | Modify | Ajout `category`, `workflow` | +| `frontend/services/dto/project.ts` | Modify | Ajout `workflow` (embedded) | +| `frontend/components/admin/AdminWorkflowTab.vue` | Create | Onglet admin liste workflows + édition inline statuts | +| `frontend/components/admin/WorkflowDrawer.vue` | Create | Drawer création/édition workflow (nom + statuts) | +| `frontend/components/admin/AdminStatusTab.vue` | **Delete** | Fusionné dans AdminWorkflowTab | +| `frontend/pages/admin.vue` | Modify | Remplace l'onglet Statuts par l'onglet Workflows | +| `frontend/pages/projects/[id]/index.vue` | Modify | Kanban basé sur `project.workflow.statuses` | +| `frontend/pages/projects/[id]/archives.vue` | Modify | Filtre statut limité au workflow du projet | +| `frontend/pages/my-tasks.vue` | Modify | Kanban groupé par catégorie (5 colonnes) + badge statut sur cards | +| `frontend/components/task/TaskCard.vue` | Modify | Affichage du badge statut (label+couleur) en mode `show-status-badge` | +| `frontend/components/project/ProjectWorkflowSwitchModal.vue` | Create | Modal de migration source→cible | +| `frontend/components/project/ProjectDrawer.vue` | Modify | Affiche workflow + bouton "Changer de workflow" | +| `frontend/components/task/TaskBulkActions.vue` | Modify | Désactive le bulk-status si multi-projets | +| `frontend/i18n/locales/fr.json` | Modify | Traductions workflows | +| `frontend/i18n/locales/en.json` | Modify | Traductions workflows | + +--- + +## Phase 1 — Backend : enum + entité + migrations + +## Task 1: StatusCategory enum + +**Files:** +- Create: `src/Enum/StatusCategory.php` + +- [ ] **Step 1: Écrire l'enum** + +```php +findOneBy(['isDefault' => true]); + } +} +``` + +- [ ] **Step 2: Entité Workflow** + +```php + ['workflow:read']], + denormalizationContext: ['groups' => ['workflow:write']], + order: ['position' => 'ASC'], +)] +#[ORM\Entity(repositoryClass: WorkflowRepository::class)] +#[UniqueEntity(fields: ['name'], message: 'Ce nom de workflow est déjà utilisé.')] +class Workflow +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['workflow:read', 'project:read', 'task_status:read'])] + private ?int $id = null; + + #[ORM\Column(length: 255, unique: true)] + #[Groups(['workflow:read', 'workflow:write', 'project:read'])] + #[Assert\NotBlank] + private ?string $name = null; + + #[ORM\Column(type: 'boolean', options: ['default' => false])] + #[Groups(['workflow:read', 'workflow:write'])] + private bool $isDefault = false; + + #[ORM\Column(type: 'integer', options: ['default' => 0])] + #[Groups(['workflow:read', 'workflow:write'])] + private int $position = 0; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: TaskStatus::class, mappedBy: 'workflow', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['position' => 'ASC'])] + #[Groups(['workflow:read', 'project:read'])] + private Collection $statuses; + + public function __construct() + { + $this->statuses = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function isDefault(): bool + { + return $this->isDefault; + } + + public function setIsDefault(bool $isDefault): static + { + $this->isDefault = $isDefault; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } + + /** @return Collection */ + public function getStatuses(): Collection + { + return $this->statuses; + } + + public function addStatus(TaskStatus $status): static + { + if (!$this->statuses->contains($status)) { + $this->statuses->add($status); + $status->setWorkflow($this); + } + + return $this; + } + + public function removeStatus(TaskStatus $status): static + { + $this->statuses->removeElement($status); + + return $this; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/Workflow.php src/Repository/WorkflowRepository.php +git commit -m "feat(workflow) : ajoute l'entité Workflow et son repository" +``` + +> Note : `WorkflowDeleteProcessor` est référencé ici mais sera créé en Task 14. Le code compile mais l'opération Delete restera non fonctionnelle jusque-là — c'est OK, on n'écrira pas dans la table avant Task 9 (migrations). + +--- + +## Task 3: Modifications TaskStatus (workflow + category) + +**Files:** +- Modify: `src/Entity/TaskStatus.php` + +- [ ] **Step 1: Ajouter les imports et propriétés** + +Remplacer le contenu de `src/Entity/TaskStatus.php` par : + +```php + ['task_status:read']], + denormalizationContext: ['groups' => ['task_status:write']], + order: ['position' => 'ASC'], +)] +#[ORM\Entity(repositoryClass: TaskStatusRepository::class)] +class TaskStatus +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['task_status:read', 'task:read', 'workflow:read', 'project:read'])] + private ?int $id = null; + + #[ORM\Column(length: 255)] + #[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])] + private ?string $label = null; + + #[ORM\Column(length: 7)] + #[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])] + private ?string $color = '#222783'; + + #[ORM\Column] + #[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])] + private ?int $position = 0; + + #[ORM\Column(type: 'boolean')] + #[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])] + private bool $isFinal = false; + + #[ORM\ManyToOne(targetEntity: Workflow::class, inversedBy: 'statuses')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['task_status:read', 'task_status:write', 'task:read'])] + #[Assert\NotNull] + private ?Workflow $workflow = null; + + #[ORM\Column(type: 'string', length: 32, enumType: StatusCategory::class)] + #[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])] + #[Assert\NotNull] + private ?StatusCategory $category = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getColor(): ?string + { + return $this->color; + } + + public function setColor(string $color): static + { + $this->color = $color; + + return $this; + } + + public function getPosition(): ?int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } + + public function getIsFinal(): bool + { + return $this->isFinal; + } + + public function setIsFinal(bool $isFinal): static + { + $this->isFinal = $isFinal; + + return $this; + } + + public function getWorkflow(): ?Workflow + { + return $this->workflow; + } + + public function setWorkflow(?Workflow $workflow): static + { + $this->workflow = $workflow; + + return $this; + } + + public function getCategory(): ?StatusCategory + { + return $this->category; + } + + public function setCategory(StatusCategory $category): static + { + $this->category = $category; + + return $this; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/TaskStatus.php +git commit -m "feat(workflow) : ajoute workflow et category sur TaskStatus" +``` + +--- + +## Task 4: Modifications Project (workflow embarqué) + +**Files:** +- Modify: `src/Entity/Project.php` + +- [ ] **Step 1: Ajouter la relation Workflow** + +Dans `src/Entity/Project.php`, ajouter après l'import `Doctrine\ORM\Mapping as ORM;` : + +```php +use Symfony\Component\Validator\Constraints as Assert; +``` + +(s'il n'est pas déjà importé) + +Puis ajouter la propriété après `private ?Client $client = null;` (vers ligne 70) : + +```php + #[ORM\ManyToOne(targetEntity: Workflow::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')] + #[Groups(['project:read', 'project:write', 'task:read'])] + #[Assert\NotNull(message: 'Un projet doit avoir un workflow.')] + private ?Workflow $workflow = null; +``` + +Puis ajouter les accesseurs à la fin de la classe (avant `getTaskCount`) : + +```php + public function getWorkflow(): ?Workflow + { + return $this->workflow; + } + + public function setWorkflow(Workflow $workflow): static + { + $this->workflow = $workflow; + + return $this; + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/Project.php +git commit -m "feat(workflow) : ajoute workflow requis sur Project (RESTRICT)" +``` + +--- + +## Task 5: Migration M1 — table workflow + insert Standard + +**Files:** +- Create: `migrations/Version_create_workflow.php` + +- [ ] **Step 1: Générer le squelette** + +```bash +docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate +``` + +Renommer le fichier généré en `Version_create_workflow.php` pour clarté (Doctrine n'impose pas le nom). + +- [ ] **Step 2: Remplir le up/down** + +```php + extends AbstractMigration +{ + public function getDescription(): string + { + return 'Create workflow table and seed default Standard workflow'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE workflow ( + id SERIAL NOT NULL, + name VARCHAR(255) NOT NULL, + is_default BOOLEAN DEFAULT FALSE NOT NULL, + position INT DEFAULT 0 NOT NULL, + PRIMARY KEY (id) + )'); + $this->addSql('CREATE UNIQUE INDEX uniq_workflow_name ON workflow (name)'); + $this->addSql("CREATE UNIQUE INDEX uniq_workflow_one_default ON workflow (is_default) WHERE is_default = TRUE"); + + $this->addSql("INSERT INTO workflow (name, is_default, position) VALUES ('Standard', TRUE, 0)"); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE workflow'); + } +} +``` + +- [ ] **Step 3: Vérifier syntaxe** + +```bash +docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:list +``` + +Expected: la nouvelle migration apparaît "Not migrated". + +- [ ] **Step 4: Commit** + +```bash +git add migrations/Version_create_workflow.php +git commit -m "feat(workflow) : migration M1 - création table workflow + seed Standard" +``` + +--- + +## Task 6: Migration M2 — task_status.workflow_id + category + backfill strict + +**Files:** +- Create: `migrations/Version_add_workflow_to_task_status.php` + +- [ ] **Step 1: Générer + remplir** + +```bash +docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate +``` + +```php + extends AbstractMigration +{ + public function getDescription(): string + { + return 'Attach existing TaskStatus rows to Standard workflow and backfill category (strict mapping)'; + } + + public function up(Schema $schema): void + { + // 1) Récupérer l'id du workflow Standard + $standardId = $this->connection->fetchOne("SELECT id FROM workflow WHERE name = 'Standard'"); + if (!$standardId) { + throw new MigrationException('Workflow Standard introuvable. Lancer M1 d\'abord.'); + } + + // 2) Garde-fou : vérifier qu'il n'y a pas de label hors mapping + $mapping = [ + 'A faire' => 'todo', + 'À faire' => 'todo', + 'En cours' => 'in_progress', + 'Bloqué' => 'blocked', + 'En attente de validation' => 'review', + 'Terminé' => 'done', + ]; + $rows = $this->connection->fetchAllAssociative('SELECT id, label FROM task_status'); + foreach ($rows as $row) { + if (!isset($mapping[$row['label']])) { + throw new MigrationException(sprintf( + 'TaskStatus #%d ("%s") n\'est pas mappable. Ajoutez son mapping dans la migration avant de relancer.', + $row['id'], + $row['label'], + )); + } + } + + // 3) Ajouter colonnes nullable + $this->addSql('ALTER TABLE task_status ADD COLUMN workflow_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE task_status ADD COLUMN category VARCHAR(32) DEFAULT NULL'); + + // 4) Backfill + $this->addSql("UPDATE task_status SET workflow_id = $standardId"); + foreach ($mapping as $label => $cat) { + $this->addSql(sprintf( + "UPDATE task_status SET category = '%s' WHERE label = '%s'", + $cat, + str_replace("'", "''", $label), + )); + } + + // 5) NOT NULL + FK + $this->addSql('ALTER TABLE task_status ALTER COLUMN workflow_id SET NOT NULL'); + $this->addSql('ALTER TABLE task_status ALTER COLUMN category SET NOT NULL'); + $this->addSql('ALTER TABLE task_status ADD CONSTRAINT FK_task_status_workflow FOREIGN KEY (workflow_id) REFERENCES workflow (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('CREATE INDEX IDX_task_status_workflow ON task_status (workflow_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE task_status DROP CONSTRAINT FK_task_status_workflow'); + $this->addSql('DROP INDEX IDX_task_status_workflow'); + $this->addSql('ALTER TABLE task_status DROP COLUMN workflow_id'); + $this->addSql('ALTER TABLE task_status DROP COLUMN category'); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add migrations/Version_add_workflow_to_task_status.php +git commit -m "feat(workflow) : migration M2 - rattache les statuts existants à Standard + category" +``` + +--- + +## Task 7: Migration M3 — project.workflow_id (RESTRICT) + +**Files:** +- Create: `migrations/Version_add_workflow_to_project.php` + +- [ ] **Step 1: Générer + remplir** + +```bash +docker exec -t php-lesstime-fpm php bin/console doctrine:migrations:generate +``` + +```php + extends AbstractMigration +{ + public function getDescription(): string + { + return 'Attach existing projects to Standard workflow (NOT NULL, RESTRICT)'; + } + + public function up(Schema $schema): void + { + $standardId = $this->connection->fetchOne("SELECT id FROM workflow WHERE name = 'Standard'"); + if (!$standardId) { + throw new MigrationException('Workflow Standard introuvable.'); + } + + $this->addSql('ALTER TABLE project ADD COLUMN workflow_id INT DEFAULT NULL'); + $this->addSql("UPDATE project SET workflow_id = $standardId"); + $this->addSql('ALTER TABLE project ALTER COLUMN workflow_id SET NOT NULL'); + $this->addSql('ALTER TABLE project ADD CONSTRAINT FK_project_workflow FOREIGN KEY (workflow_id) REFERENCES workflow (id) ON DELETE RESTRICT NOT DEFERRABLE'); + $this->addSql('CREATE INDEX IDX_project_workflow ON project (workflow_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project DROP CONSTRAINT FK_project_workflow'); + $this->addSql('DROP INDEX IDX_project_workflow'); + $this->addSql('ALTER TABLE project DROP COLUMN workflow_id'); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add migrations/Version_add_workflow_to_project.php +git commit -m "feat(workflow) : migration M3 - workflow requis sur Project (RESTRICT)" +``` + +--- + +## Task 8: Mise à jour des fixtures (workflow Standard + statuts attachés) + +**Files:** +- Modify: `src/DataFixtures/AppFixtures.php` + +- [ ] **Step 1: Ajouter l'import** + +En haut du fichier, ajouter : + +```php +use App\Entity\Workflow; +use App\Enum\StatusCategory; +``` + +- [ ] **Step 2: Créer le workflow Standard avant les statuts** + +Localiser le bloc `// Task Statuses (global)` (vers ligne 122). Remplacer le bloc par : + +```php + // Workflow par défaut + $standardWorkflow = new Workflow(); + $standardWorkflow->setName('Standard'); + $standardWorkflow->setIsDefault(true); + $standardWorkflow->setPosition(0); + $manager->persist($standardWorkflow); + + // Task Statuses (rattachés au workflow Standard) + $defaultStatuses = [ + ['A faire', '#222783', 0, StatusCategory::Todo, false], + ['En cours', '#4A90D9', 1, StatusCategory::InProgress, false], + ['Bloqué', '#C62828', 2, StatusCategory::Blocked, false], + ['En attente de validation', '#FF8F00', 3, StatusCategory::Review, false], + ['Terminé', '#26A69A', 4, StatusCategory::Done, true], + ]; + + $statusObjects = []; + foreach ($defaultStatuses as [$label, $color, $position, $category, $isFinal]) { + $status = new TaskStatus(); + $status->setLabel($label); + $status->setColor($color); + $status->setPosition($position); + $status->setCategory($category); + $status->setIsFinal($isFinal); + $standardWorkflow->addStatus($status); + $manager->persist($status); + $statusObjects[$label] = $status; + } + + $statusTodo = $statusObjects['A faire']; + $statusInProgress = $statusObjects['En cours']; + $statusBlocked = $statusObjects['Bloqué']; + $statusReview = $statusObjects['En attente de validation']; + $statusDone = $statusObjects['Terminé']; +``` + +- [ ] **Step 3: Assigner le workflow Standard à tous les projets fixtures** + +Localiser chaque création de `Project` (recherche `new Project()`). Pour chacun, ajouter juste après `setColor` : + +```php + $project->setWorkflow($standardWorkflow); +``` + +(remplacer `` par le nom de variable du projet : `$projectClient`, `$projectInterne`, etc.) + +- [ ] **Step 4: Commit** + +```bash +git add src/DataFixtures/AppFixtures.php +git commit -m "feat(workflow) : fixtures - workflow Standard + statuts catégorisés + projets attachés" +``` + +--- + +## Task 9: Listener unique-default-workflow + +**Files:** +- Create: `src/EventListener/UniqueDefaultWorkflowListener.php` + +- [ ] **Step 1: Écrire le listener** + +```php +getObjectManager(); + $uow = $em->getUnitOfWork(); + + $candidates = []; + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if ($entity instanceof Workflow && $entity->isDefault()) { + $candidates[] = $entity; + } + } + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if ($entity instanceof Workflow && $entity->isDefault()) { + $candidates[] = $entity; + } + } + + if (count($candidates) === 0) { + return; + } + + // Démarquer tous les autres workflows + $metadata = $em->getClassMetadata(Workflow::class); + $repo = $em->getRepository(Workflow::class); + foreach ($repo->findBy(['isDefault' => true]) as $existing) { + if (in_array($existing, $candidates, true)) { + continue; + } + $existing->setIsDefault(false); + $uow->recomputeSingleEntityChangeSet($metadata, $existing); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/EventListener/UniqueDefaultWorkflowListener.php +git commit -m "feat(workflow) : listener garantissant un seul workflow isDefault=true" +``` + +--- + +## Task 10: Appliquer migrations + fixtures + smoke test + +**Files:** (aucun fichier source — vérification runtime) + +- [ ] **Step 1: Lancer les migrations** + +```bash +make migration-migrate +``` + +Expected: les 3 migrations passent vert. + +- [ ] **Step 2: Vérifier le schéma** + +```bash +docker exec -t php-lesstime-fpm php bin/console doctrine:schema:validate +``` + +Expected: "The mapping files are correct." et "The database schema is in sync with the mapping files." + +- [ ] **Step 3: Recharger les fixtures** + +```bash +make fixtures +``` + +Expected: pas d'erreur. + +- [ ] **Step 4: Vérifier le contenu via psql** + +```bash +docker exec -t php-lesstime-fpm bash -c "PGPASSWORD=malio psql -h host.docker.internal -p 5432 -U malio -d lesstime -c 'SELECT id, name, is_default FROM workflow'" +docker exec -t php-lesstime-fpm bash -c "PGPASSWORD=malio psql -h host.docker.internal -p 5432 -U malio -d lesstime -c 'SELECT id, label, category, workflow_id FROM task_status ORDER BY position'" +docker exec -t php-lesstime-fpm bash -c "PGPASSWORD=malio psql -h host.docker.internal -p 5432 -U malio -d lesstime -c 'SELECT id, code, workflow_id FROM project'" +``` + +Expected: workflow Standard existe, 5 statuts avec category remplie, tous les projets ont `workflow_id` non-null. + +- [ ] **Step 5: Smoke test API** + +```bash +docker exec -t php-lesstime-fpm curl -sS http://localhost/api/workflows -H "Cookie: BEARER=$(echo)" | head -50 +``` + +(Test alternatif via UI : se logger en admin et appeler `/api/workflows` depuis devtools) + +--- + +## Phase 2 — Sérialisation, validation cross-entity, Delete protection + +## Task 11: Validation cross-entity sur Task (status.workflow == project.workflow) + +**Files:** +- Modify: `src/Entity/Task.php` + +- [ ] **Step 1: Ajouter la validation** + +Dans `src/Entity/Task.php`, ajouter à la fin de la classe (après `validateCollaborators`) : + +```php + #[Assert\Callback] + public function validateStatusBelongsToProjectWorkflow(ExecutionContextInterface $context): void + { + if (null === $this->status || null === $this->project) { + return; + } + + $projectWorkflow = $this->project->getWorkflow(); + $statusWorkflow = $this->status->getWorkflow(); + + if (null === $projectWorkflow || null === $statusWorkflow) { + return; // états transitoires + } + + if ($projectWorkflow->getId() !== $statusWorkflow->getId()) { + $context->buildViolation('Status does not belong to this project\'s workflow.') + ->atPath('status') + ->addViolation() + ; + } + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/Task.php +git commit -m "feat(workflow) : valide que task.status appartient au workflow du projet" +``` + +--- + +## Task 12: WorkflowDeleteProcessor (409 si projets liés) + +**Files:** +- Create: `src/State/WorkflowDeleteProcessor.php` + +- [ ] **Step 1: Test fonctionnel d'abord (TDD)** + +Créer `tests/Functional/WorkflowDeleteProtectionTest.php` : + +```php +loginUser($this->getAdmin()); + + // Le workflow Standard est lié à tous les projets fixtures + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get(EntityManagerInterface::class); + $standard = $em->getRepository(Workflow::class)->findOneBy(['name' => 'Standard']); + self::assertNotNull($standard); + + $client->request('DELETE', '/api/workflows/' . $standard->getId()); + + self::assertResponseStatusCodeSame(409); + self::assertJsonContains(['title' => 'Workflow used by linked projects']); + } + + private function getAdmin(): \App\Entity\User + { + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(\App\Entity\User::class)->findOneBy(['username' => 'admin']); + self::assertNotNull($user); + + return $user; + } +} +``` + +- [ ] **Step 2: Lancer le test (FAIL attendu)** + +```bash +make test -- --filter WorkflowDeleteProtectionTest +``` + +Expected: échec (le processor n'existe pas encore). + +- [ ] **Step 3: Implémenter le processor** + +```php + + */ +final readonly class WorkflowDeleteProcessor implements ProcessorInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void + { + /** @var Workflow $workflow */ + $workflow = $data; + + $count = (int) $this->entityManager->getConnection()->fetchOne( + 'SELECT COUNT(*) FROM project WHERE workflow_id = :id', + ['id' => $workflow->getId()], + ); + + if ($count > 0) { + throw new HttpException(409, sprintf( + 'Workflow used by %d project(s). Reassign them before deleting.', + $count, + )); + } + + $this->entityManager->remove($workflow); + $this->entityManager->flush(); + } +} +``` + +- [ ] **Step 4: Lancer le test (PASS attendu)** + +```bash +make test -- --filter WorkflowDeleteProtectionTest +``` + +Expected: green. + +- [ ] **Step 5: Commit** + +```bash +git add src/State/WorkflowDeleteProcessor.php tests/Functional/WorkflowDeleteProtectionTest.php +git commit -m "feat(workflow) : protège la suppression d'un workflow lié à des projets (409)" +``` + +--- + +## Task 13: Test fonctionnel — validation cross-entity Task + +**Files:** +- Create: `tests/Functional/TaskWorkflowValidationTest.php` + +- [ ] **Step 1: Écrire le test** + +```php +get(EntityManagerInterface::class); + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + + // Workflow B : un workflow alternatif avec ses propres statuts + $workflowB = (new Workflow())->setName('AltKanban')->setPosition(1); + $statusB = (new TaskStatus()) + ->setLabel('Backlog') + ->setColor('#000000') + ->setPosition(0) + ->setCategory(StatusCategory::Todo); + $workflowB->addStatus($statusB); + $em->persist($workflowB); + $em->flush(); + + // Project sur workflow Standard (fixtures) + $project = $em->getRepository(Project::class)->findOneBy([]); + self::assertNotNull($project); + self::assertNotSame($project->getWorkflow()->getId(), $workflowB->getId()); + + $response = $client->request('POST', '/api/tasks', [ + 'json' => [ + 'title' => 'Test mismatch', + 'project' => '/api/projects/' . $project->getId(), + 'status' => '/api/task_statuses/' . $statusB->getId(), + ], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertJsonContains(['violations' => [['message' => 'Status does not belong to this project\'s workflow.']]]); + } +} +``` + +- [ ] **Step 2: Lancer + vérifier green** + +```bash +make test -- --filter TaskWorkflowValidationTest +``` + +- [ ] **Step 3: Commit** + +```bash +git add tests/Functional/TaskWorkflowValidationTest.php +git commit -m "test(workflow) : valide le rejet d'un status hors workflow projet" +``` + +--- + +## Phase 3 — Endpoint switch-workflow + +## Task 14: SwitchProjectWorkflowProcessor + +**Files:** +- Create: `src/State/SwitchProjectWorkflowProcessor.php` + +- [ ] **Step 1: Test fonctionnel (TDD)** + +Créer `tests/Functional/SwitchProjectWorkflowTest.php` : + +```php +get(EntityManagerInterface::class); + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + + // Workflow B + $workflowB = (new Workflow())->setName('DevKanban')->setPosition(1); + $statusBDev = (new TaskStatus())->setLabel('In Dev')->setColor('#0F0')->setPosition(0)->setCategory(StatusCategory::InProgress); + $workflowB->addStatus($statusBDev); + $em->persist($workflowB); + + // Project Standard avec une tâche "En cours" + $project = $em->getRepository(Project::class)->findOneBy([]); + $standardEnCours = $em->getRepository(TaskStatus::class)->findOneBy([ + 'label' => 'En cours', + 'workflow' => $project->getWorkflow(), + ]); + self::assertNotNull($standardEnCours); + + $task = (new Task()) + ->setNumber(9999) + ->setTitle('Test switch') + ->setProject($project) + ->setStatus($standardEnCours); + $em->persist($task); + $em->flush(); + + $client->request('POST', '/api/projects/' . $project->getId() . '/switch-workflow', [ + 'json' => [ + 'workflowId' => $workflowB->getId(), + 'mapping' => [ + (string) $standardEnCours->getId() => $statusBDev->getId(), + ], + ], + ]); + + self::assertResponseIsSuccessful(); + self::assertJsonContains(['migratedTaskCount' => 1]); + + $em->refresh($task); + $em->refresh($project); + self::assertSame($workflowB->getId(), $project->getWorkflow()->getId()); + self::assertSame($statusBDev->getId(), $task->getStatus()->getId()); + } + + public function testSwitchFailsWhenMappingIsIncomplete(): void + { + $client = self::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + $client->loginUser($admin); + + $workflowB = (new Workflow())->setName('Solo')->setPosition(2); + $statusBOnly = (new TaskStatus())->setLabel('Only')->setColor('#FFF')->setPosition(0)->setCategory(StatusCategory::Todo); + $workflowB->addStatus($statusBOnly); + $em->persist($workflowB); + + $project = $em->getRepository(Project::class)->findOneBy([]); + $standardEnCours = $em->getRepository(TaskStatus::class)->findOneBy([ + 'label' => 'En cours', + 'workflow' => $project->getWorkflow(), + ]); + $em->persist((new Task())->setNumber(9998)->setTitle('Uncovered')->setProject($project)->setStatus($standardEnCours)); + $em->flush(); + + $client->request('POST', '/api/projects/' . $project->getId() . '/switch-workflow', [ + 'json' => [ + 'workflowId' => $workflowB->getId(), + 'mapping' => [], // mapping vide + ], + ]); + + self::assertResponseStatusCodeSame(422); + } +} +``` + +- [ ] **Step 2: Lancer (FAIL attendu)** + +```bash +make test -- --filter SwitchProjectWorkflowTest +``` + +- [ ] **Step 3: Créer le DTO de sortie** + +`src/ApiResource/SwitchWorkflowOutput.php` : + +```php +projectId = $projectId; + $this->workflowId = $workflowId; + $this->migratedTaskCount = $migratedTaskCount; + } +} +``` + +- [ ] **Step 4: Implémenter le processor** + +```php + + */ +final readonly class SwitchProjectWorkflowProcessor implements ProcessorInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SwitchWorkflowOutput + { + /** @var Project $project */ + $project = $data; + + $request = $context['request'] ?? null; + $body = $request ? json_decode($request->getContent(), true) : []; + + $workflowId = $body['workflowId'] ?? null; + $mapping = $body['mapping'] ?? []; + + if (!is_int($workflowId) || !is_array($mapping)) { + throw new HttpException(422, 'Body must contain workflowId (int) and mapping (object).'); + } + + $targetWorkflow = $this->entityManager->find(Workflow::class, $workflowId); + if (!$targetWorkflow instanceof Workflow) { + throw new NotFoundHttpException('Target workflow not found.'); + } + + // 1) Lister les statuts source effectivement référencés par les tâches du projet + $rows = $this->entityManager->getConnection()->fetchAllAssociative( + 'SELECT DISTINCT status_id FROM task WHERE project_id = :pid AND status_id IS NOT NULL', + ['pid' => $project->getId()], + ); + $referencedSourceIds = array_map(static fn ($r) => (int) $r['status_id'], $rows); + + // 2) Vérifier que chaque source a un mapping + $missing = []; + foreach ($referencedSourceIds as $srcId) { + if (!array_key_exists((string) $srcId, $mapping)) { + $missing[] = $srcId; + } + } + if ($missing !== []) { + throw new HttpException(422, 'Missing mapping for source status IDs: ' . implode(', ', $missing)); + } + + // 3) Valider que chaque target appartient au workflow cible (ou est null) + foreach ($mapping as $srcId => $targetId) { + if (null === $targetId) { + continue; + } + $target = $this->entityManager->find(TaskStatus::class, $targetId); + if (!$target instanceof TaskStatus + || $target->getWorkflow()?->getId() !== $targetWorkflow->getId()) { + throw new HttpException(422, sprintf( + 'Target status %s does not belong to workflow %d.', + var_export($targetId, true), + $targetWorkflow->getId(), + )); + } + } + + // 4) Transaction unique + $conn = $this->entityManager->getConnection(); + $conn->beginTransaction(); + try { + $migrated = 0; + foreach ($mapping as $srcId => $targetId) { + $affected = $conn->executeStatement( + 'UPDATE task SET status_id = :tid WHERE project_id = :pid AND status_id = :sid', + ['tid' => $targetId, 'pid' => $project->getId(), 'sid' => (int) $srcId], + ); + $migrated += $affected; + } + + $project->setWorkflow($targetWorkflow); + $this->entityManager->flush(); + $conn->commit(); + } catch (\Throwable $e) { + $conn->rollBack(); + throw $e; + } + + return new SwitchWorkflowOutput( + projectId: $project->getId(), + workflowId: $targetWorkflow->getId(), + migratedTaskCount: $migrated, + ); + } +} +``` + +- [ ] **Step 5: Lancer le test (toujours FAIL : il manque l'endpoint exposé)** + +Continuer à la tâche suivante. + +--- + +## Task 15: Exposer l'endpoint /switch-workflow + +**Files:** +- Modify: `src/Entity/Project.php` + +- [ ] **Step 1: Ajouter l'opération Post custom** + +Dans `src/Entity/Project.php`, ajouter en haut des imports : + +```php +use ApiPlatform\Metadata\Link; +use App\State\SwitchProjectWorkflowProcessor; +``` + +Modifier la liste des `operations` de `#[ApiResource]` en ajoutant après le `Delete` : + +```php + new Post( + uriTemplate: '/projects/{id}/switch-workflow', + uriVariables: ['id' => new Link(fromClass: Project::class)], + security: "is_granted('ROLE_ADMIN')", + input: false, + output: SwitchWorkflowOutput::class, + normalizationContext: ['groups' => ['switch_workflow:read']], + processor: SwitchProjectWorkflowProcessor::class, + read: true, + deserialize: false, + validate: false, + write: false, + name: 'switch_workflow', + ), +``` + +> `input: false` désactive la deserialization (on lit le body brut dans le processor). `output: SwitchWorkflowOutput::class` indique à API Platform que la réponse a la forme du DTO — la sérialisation utilise le groupe `switch_workflow:read`. `read: true` charge l'entité Project depuis l'id de l'URL. + +- [ ] **Step 2: Lancer les tests du switch (PASS attendu)** + +```bash +make test -- --filter SwitchProjectWorkflowTest +``` + +Expected: 2 tests verts. + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/Project.php src/State/SwitchProjectWorkflowProcessor.php src/ApiResource/SwitchWorkflowOutput.php tests/Functional/SwitchProjectWorkflowTest.php +git commit -m "feat(workflow) : endpoint POST /projects/{id}/switch-workflow + processor transactionnel" +``` + +--- + +## Phase 4 — Frontend : DTOs + service workflows + +## Task 16: DTO Workflow + extension TaskStatus + Project + +**Files:** +- Create: `frontend/services/dto/workflow.ts` +- Modify: `frontend/services/dto/task-status.ts` +- Modify: `frontend/services/dto/project.ts` + +- [ ] **Step 1: Créer workflow.ts** + +```ts +import type { TaskStatus, TaskStatusWrite } from './task-status' + +export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done' + +export const STATUS_CATEGORY_LABEL: Record = { + todo: 'À faire', + in_progress: 'En cours', + blocked: 'Bloqué', + review: 'En validation', + done: 'Terminé', +} + +export type Workflow = { + id: number + '@id'?: string + name: string + isDefault: boolean + position: number + statuses: TaskStatus[] +} + +export type WorkflowWrite = { + name: string + isDefault: boolean + position: number + statuses?: TaskStatusWrite[] +} +``` + +- [ ] **Step 2: Étendre task-status.ts** + +```ts +import type { StatusCategory } from './workflow' + +export type TaskStatus = { + id: number + '@id'?: string + label: string + color: string + position: number + isFinal: boolean + category: StatusCategory + workflow?: { '@id': string, id: number } | string +} + +export type TaskStatusWrite = { + label: string + color: string + position: number + isFinal: boolean + category: StatusCategory + workflow?: string // IRI : "/api/workflows/1" +} +``` + +- [ ] **Step 3: Étendre project.ts** + +Ajouter l'import : + +```ts +import type { Workflow } from './workflow' +``` + +Étendre les types : + +```ts +export type Project = { + id: number + '@id'?: string + code: string + name: string + description: string | null + color: string + client: Client | null + workflow: Workflow + giteaOwner: string | null + giteaRepo: string | null + bookstackShelfId: number | null + bookstackShelfName: string | null + archived: boolean + taskCount: number +} + +export type ProjectWrite = { + code?: string + name: string + description: string | null + color: string + client: string | null + workflow?: string // IRI : "/api/workflows/1" + giteaOwner?: string | null + giteaRepo?: string | null + bookstackShelfId?: number | null + bookstackShelfName?: string | null + archived?: boolean +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/services/dto/workflow.ts frontend/services/dto/task-status.ts frontend/services/dto/project.ts +git commit -m "feat(workflow) : DTOs front Workflow + category sur TaskStatus + workflow embarqué sur Project" +``` + +--- + +## Task 17: Service workflows.ts + +**Files:** +- Create: `frontend/services/workflows.ts` + +- [ ] **Step 1: Écrire le service** + +```ts +import type { Workflow, WorkflowWrite } from './dto/workflow' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +type SwitchPayload = { + workflowId: number + mapping: Record +} + +export function useWorkflowService() { + const api = useApi() + + async function getAll(): Promise { + const data = await api.get>('/workflows') + return extractHydraMembers(data) + } + + async function getOne(id: number): Promise { + return api.get(`/workflows/${id}`) + } + + async function create(payload: WorkflowWrite): Promise { + return api.post('/workflows', payload as Record, { + toastSuccessKey: 'workflows.created', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/workflows/${id}`, payload as Record, { + toastSuccessKey: 'workflows.updated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/workflows/${id}`, {}, { + toastSuccessKey: 'workflows.deleted', + }) + } + + async function switchOnProject(projectId: number, payload: SwitchPayload): Promise<{ migratedTaskCount: number }> { + return api.post<{ migratedTaskCount: number }>( + `/projects/${projectId}/switch-workflow`, + payload as unknown as Record, + { toastSuccessKey: 'workflows.switched' }, + ) + } + + return { getAll, getOne, create, update, remove, switchOnProject } +} +``` + +- [ ] **Step 2: Ajouter les traductions** + +Dans `frontend/i18n/locales/fr.json`, ajouter la section : + +```json + "workflows": { + "title": "Workflows", + "addWorkflow": "Ajouter un workflow", + "editWorkflow": "Modifier le workflow", + "name": "Nom", + "isDefault": "Workflow par défaut", + "statuses": "Statuts", + "addStatus": "Ajouter un statut", + "category": "Catégorie", + "created": "Workflow créé", + "updated": "Workflow mis à jour", + "deleted": "Workflow supprimé", + "switched": "Workflow du projet changé", + "switchTitle": "Changer de workflow", + "switchTargetLabel": "Nouveau workflow", + "switchMappingTitle": "Mapping des statuts", + "switchSourceCol": "Statut actuel", + "switchTargetCol": "Statut cible", + "switchTaskCountCol": "Tâches", + "switchToBacklog": "Mapper vers le backlog", + "switchConfirm": "Confirmer la migration", + "switchSummary": "{count} tâche(s) migrée(s), projet sur workflow « {name} »", + "deleteUsedBy": "Workflow utilisé par {count} projet(s) — impossible de supprimer.", + "categories": { + "todo": "À faire", + "in_progress": "En cours", + "blocked": "Bloqué", + "review": "En validation", + "done": "Terminé" + } + } +``` + +Dans `frontend/i18n/locales/en.json`, ajouter l'équivalent anglais (mêmes clés, libellés anglais). + +- [ ] **Step 3: Commit** + +```bash +git add frontend/services/workflows.ts frontend/i18n/locales/fr.json frontend/i18n/locales/en.json +git commit -m "feat(workflow) : service front workflows + i18n" +``` + +--- + +## Phase 5 — Admin UI + +## Task 18: WorkflowDrawer (création/édition workflow + statuts inline) + +**Files:** +- Create: `frontend/components/admin/WorkflowDrawer.vue` + +- [ ] **Step 1: Écrire le composant** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/admin/WorkflowDrawer.vue +git commit -m "feat(workflow) : WorkflowDrawer - création/édition workflow et statuts inline" +``` + +--- + +## Task 19: AdminWorkflowTab + +**Files:** +- Create: `frontend/components/admin/AdminWorkflowTab.vue` + +- [ ] **Step 1: Écrire le composant** + +```vue + + + +``` + +> Note : la colonne `isDefault` utilise la slot `cell-isDefault` (cf. DataTable patterns existants comme `cell-color` dans AdminStatusTab). Si le typage de `DataTableColumn.label` n'accepte pas une fonction, remplacer par une string statique `t('workflows.isDefault')` calculée hors du tableau littéral. + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/admin/AdminWorkflowTab.vue +git commit -m "feat(workflow) : AdminWorkflowTab - liste et gestion des workflows" +``` + +--- + +## Task 20: Remplacer l'onglet Statuts par l'onglet Workflows + +**Files:** +- Modify: `frontend/pages/admin.vue` +- Delete: `frontend/components/admin/AdminStatusTab.vue` + +- [ ] **Step 1: Mettre à jour admin.vue** + +Dans `frontend/pages/admin.vue`, remplacer la ligne `` par : + +```vue + +``` + +Et dans la liste `tabs`, remplacer `{ key: 'statuses', label: 'Statuts' }` par : + +```ts + { key: 'workflows', label: 'Workflows' }, +``` + +- [ ] **Step 2: Supprimer le fichier obsolète** + +```bash +git rm frontend/components/admin/AdminStatusTab.vue +``` + +- [ ] **Step 3: Smoke test** + +```bash +make dev-nuxt +``` + +Naviguer sur `http://localhost:3002/admin`, vérifier que l'onglet "Workflows" remplace "Statuts", et que la création/édition d'un workflow fonctionne. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/pages/admin.vue +git commit -m "feat(workflow) : remplace l'onglet Statuts par Workflows en admin" +``` + +--- + +## Phase 6 — Kanban projet + archives projet + +## Task 21: Adapter projects/[id]/index.vue (kanban basé sur workflow du projet) + +**Files:** +- Modify: `frontend/pages/projects/[id]/index.vue` + +- [ ] **Step 1: Lire la page actuelle pour repérer l'usage de statuses** + +```bash +grep -n "statuses\|statusService" frontend/pages/projects/[id]/index.vue +``` + +- [ ] **Step 2: Modifier le chargement** + +Remplacer l'appel `statusService.getAll()` par l'utilisation de `project.workflow.statuses` chargé via `projectService.getOne(id)`. Précisément : + +- Supprimer l'import `useTaskStatusService` et son usage. +- Après chargement du `project` courant, faire : + +```ts +const statuses = computed(() => + [...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position), +) +``` + +(adapter à la variable qui contient le projet ; si la page charge la collection complète, faire un `find` par `route.params.id`). + +> Si le code actuel utilise un `ref` pour `statuses`, remplacer par un `computed` qui dérive de `project.workflow.statuses`. + +- [ ] **Step 3: Smoke test browser** + +Logger en admin, ouvrir un projet, vérifier que les colonnes du kanban correspondent aux statuts du workflow Standard (À faire / En cours / Bloqué / En attente validation / Terminé). + +Ensuite, créer un nouveau workflow "DevKanban" via l'admin avec 3 statuts custom, l'assigner manuellement à un projet (via la console DB pour cette étape — la modal Switch arrive en Task 24), et vérifier que le kanban affiche les 3 nouvelles colonnes. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/pages/projects/[id]/index.vue +git commit -m "feat(workflow) : kanban projet basé sur les statuts du workflow du projet" +``` + +--- + +## Task 22: Adapter projects/[id]/archives.vue + +**Files:** +- Modify: `frontend/pages/projects/[id]/archives.vue` + +- [ ] **Step 1: Filtre statut limité au workflow du projet** + +Localiser le filtre statut (souvent un `MalioSelect` basé sur `statuses`). Le brancher sur `project.workflow.statuses` au lieu de la collection globale (même pattern que Task 21). + +- [ ] **Step 2: Smoke test** + +Naviguer sur `/projects//archives`, vérifier que le dropdown statut n'affiche que les statuts du workflow du projet. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/pages/projects/[id]/archives.vue +git commit -m "feat(workflow) : archives projet - filtre statut limité au workflow du projet" +``` + +--- + +## Phase 7 — my-tasks : kanban groupé par catégorie + +## Task 23: Refactor my-tasks.vue en kanban par catégorie + badge statut sur TaskCard + +**Files:** +- Modify: `frontend/pages/my-tasks.vue` +- Modify: `frontend/components/task/TaskCard.vue` + +- [ ] **Step 1: Ajouter une prop `showStatusBadge` à TaskCard** + +Dans `frontend/components/task/TaskCard.vue`, ajouter dans les props : + +```ts +const props = defineProps<{ + task: Task + showProjectColor?: boolean + showStatusBadge?: boolean +}>() +``` + +Et dans le template, là où l'on affiche déjà des badges (groupe/priorité), ajouter conditionnellement : + +```vue + + {{ task.status.label }} + +``` + +- [ ] **Step 2: Refactor my-tasks.vue** + +Remplacer la section "Kanban helpers" et le template "Kanban View" par : + +```ts +import type { StatusCategory } from '~/services/dto/workflow' +import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow' + +const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done'] + +function tasksByCategory(category: StatusCategory): Task[] { + return tasks.value.filter(t => t.status?.category === category) +} + +// Backlog = tâches sans statut, identique à avant +const backlogTasks = computed(() => tasks.value.filter(t => !t.status)) +``` + +Supprimer `sortedStatuses` et `tasksByStatus`. + +Dans le template, remplacer le bloc "Kanban View" (`
`) par : + +```vue +
+
+
+
+ {{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }}) +
+
+
+ +

+ {{ $t('myTasks.noTasks') }} +

+
+
+
+
+ + +
+

{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})

+
+ +
+

+ {{ $t('myTasks.noTasks') }} +

+
+
+``` + +> Drag & drop : on supprime le drag-to-status (changer la catégorie n'a pas de sens car la catégorie est dérivée du statut). Si Matthieu veut garder un changement de statut depuis my-tasks, ce sera via TaskModal. Supprimer les handlers `onDragEnter/onDragLeave/onDropStatus/onDropBacklog` et les attributs `@dragenter/@dragleave/@drop` du template. + +- [ ] **Step 3: Smoke test** + +Logger en user normal, aller sur `/my-tasks`, vérifier les 5 colonnes par catégorie et le badge statut sur chaque card. Vérifier que les tâches de projets différents (donc workflows potentiellement différents) cohabitent bien dans les colonnes. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/pages/my-tasks.vue frontend/components/task/TaskCard.vue +git commit -m "feat(workflow) : my-tasks - kanban groupé par catégorie avec badge statut" +``` + +--- + +## Phase 8 — Modal Switch + intégration ProjectDrawer + +## Task 24: ProjectWorkflowSwitchModal + +**Files:** +- Create: `frontend/components/project/ProjectWorkflowSwitchModal.vue` + +- [ ] **Step 1: Écrire le composant** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/project/ProjectWorkflowSwitchModal.vue +git commit -m "feat(workflow) : ProjectWorkflowSwitchModal - mapping source→cible avec pré-remplissage par catégorie" +``` + +--- + +## Task 25: Intégrer la modal dans ProjectDrawer + +**Files:** +- Modify: `frontend/components/project/ProjectDrawer.vue` + +- [ ] **Step 1: Afficher le workflow + bouton "Changer"** + +Dans `ProjectDrawer.vue`, ajouter une section "Workflow" sous les autres champs (ou à l'endroit qui convient stylistiquement) : + +```vue +
+
+
+

Workflow

+

{{ props.item.workflow.name }}

+
+ +
+
+ + +``` + +Dans ` diff --git a/frontend/components/admin/AdminWorkflowTab.vue b/frontend/components/admin/AdminWorkflowTab.vue new file mode 100644 index 0000000..0c6fbdd --- /dev/null +++ b/frontend/components/admin/AdminWorkflowTab.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/components/admin/WorkflowDrawer.vue b/frontend/components/admin/WorkflowDrawer.vue new file mode 100644 index 0000000..fbee6cd --- /dev/null +++ b/frontend/components/admin/WorkflowDrawer.vue @@ -0,0 +1,261 @@ + + + diff --git a/frontend/components/project/ProjectDrawer.vue b/frontend/components/project/ProjectDrawer.vue index f8ba6fb..db3d837 100644 --- a/frontend/components/project/ProjectDrawer.vue +++ b/frontend/components/project/ProjectDrawer.vue @@ -87,10 +87,35 @@
+
+
+
+

{{ $t('workflows.title') }}

+

{{ props.project.workflow?.name }}

+
+ +
+
+ + + @@ -122,6 +147,15 @@ const isOpen = computed({ const isEditing = computed(() => !!props.project) const isSubmitting = ref(false) const confirmDeleteOpen = ref(false) +const switchModalOpen = ref(false) + +const auth = useAuthStore() +const canManageWorkflows = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false) + +function onWorkflowSwitched() { + emit('saved') + isOpen.value = false +} const { listRepositories } = useGiteaService() const giteaRepos = ref([]) diff --git a/frontend/components/project/ProjectWorkflowSwitchModal.vue b/frontend/components/project/ProjectWorkflowSwitchModal.vue new file mode 100644 index 0000000..0fee759 --- /dev/null +++ b/frontend/components/project/ProjectWorkflowSwitchModal.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/components/task/TaskBulkActions.vue b/frontend/components/task/TaskBulkActions.vue index 5c9194a..617aa19 100644 --- a/frontend/components/task/TaskBulkActions.vue +++ b/frontend/components/task/TaskBulkActions.vue @@ -14,8 +14,9 @@
- + + + Status — + diff --git a/frontend/components/task/TaskCard.vue b/frontend/components/task/TaskCard.vue index 0a5f57b..c8a92ef 100644 --- a/frontend/components/task/TaskCard.vue +++ b/frontend/components/task/TaskCard.vue @@ -40,6 +40,13 @@
+ + {{ task.status.label }} + (), { showProjectColor: false, + showStatusBadge: false, }) const emit = defineEmits<{ diff --git a/frontend/components/task/TaskStatusDrawer.vue b/frontend/components/task/TaskStatusDrawer.vue deleted file mode 100644 index bff9b06..0000000 --- a/frontend/components/task/TaskStatusDrawer.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - diff --git a/frontend/components/ui/AppTopNav.vue b/frontend/components/ui/AppTopNav.vue index 96d371f..b8fdef2 100644 --- a/frontend/components/ui/AppTopNav.vue +++ b/frontend/components/ui/AppTopNav.vue @@ -13,6 +13,14 @@

Lesstime

+ - - -
-
-
-

{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}

- -

- {{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }} -

- -
- -
- -
- - -
-
-
- - - - - - - diff --git a/frontend/content/help/01-getting-started.md b/frontend/content/help/01-getting-started.md new file mode 100644 index 0000000..f224a0a --- /dev/null +++ b/frontend/content/help/01-getting-started.md @@ -0,0 +1,27 @@ +# Bienvenue dans Lesstime + +Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités : + +- 🗂️ **Gestion de projets** avec kanban personnalisable (workflows) +- ✅ **Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags +- ⏱️ **Time tracking** intégré, lié aux projets et aux tâches +- 🎫 **Portail client** pour que tes clients déposent leurs tickets + +## Comprendre les rôles + +| Rôle | Accès | +|---|---| +| **Admin** | Tout : projets, utilisateurs, intégrations, workflows | +| **User** | Ses tâches, time tracking, projets auxquels il a accès | +| **Client** | Portal dédié — tickets sur ses projets uniquement | + +## Vues principales + +- **Dashboard** : vue d'ensemble personnelle (KPIs, tâches du jour) +- **Mes tâches** : kanban perso groupé par catégorie, toutes projets confondus +- **Projets** : un kanban par projet, statuts du workflow associé +- **Time tracking** : timer, time entries, vue mois +- **Admin** : gestion globale (visible uniquement par les admins) +- **Portal** : interface dédiée aux utilisateurs ROLE_CLIENT + +> 💡 **Astuce** : utilise l'avatar en haut à droite pour accéder à ton profil et y générer un **token MCP** (cf. section *Token MCP & API*) pour piloter Lesstime depuis Claude / Cursor. diff --git a/frontend/content/help/02-projects-workflows.md b/frontend/content/help/02-projects-workflows.md new file mode 100644 index 0000000..9001713 --- /dev/null +++ b/frontend/content/help/02-projects-workflows.md @@ -0,0 +1,58 @@ +# Projets & Workflows + +## Qu'est-ce qu'un projet ? + +Un projet regroupe un ensemble de **tâches**, **time entries** et éventuellement **tickets client**. Il est défini par : + +- Un **code court** (2-10 lettres majuscules, ex: `SIRH`, `CRM`) qui préfixe les numéros de tâches +- Un **client** optionnel (ou interne si null) +- Une **couleur** d'identification +- Un **workflow** (obligatoire) qui définit ses colonnes kanban + +## Qu'est-ce qu'un workflow ? + +Un **workflow** est un *jeu de statuts kanban* réutilisable. Au lieu d'avoir une liste globale de statuts comme dans la plupart des outils, chaque projet a son propre kanban adapté à sa façon de travailler. + +### Exemple + +| Workflow | Statuts | +|---|---| +| **Standard** (par défaut) | À faire → En cours → Bloqué → En attente de validation → Terminé | +| **DevKanban** | Backlog → Spec → In Dev → Review PR → QA → Done | +| **Support** | Nouveau → Diagnostic → Résolu | + +Tu peux créer autant de workflows que tu veux depuis **Admin → Workflows**. + +## Les 5 catégories canoniques + +Chaque statut, peu importe son workflow, appartient à **une catégorie canonique** parmi : + +| Catégorie | Description | +|---|---| +| `todo` | À faire — pas encore commencé | +| `in_progress` | En cours — quelqu'un bosse dessus | +| `blocked` | Bloqué — attente d'une dépendance | +| `review` | En validation — relecture, PR, QA | +| `done` | Terminé — close | + +> 🎯 **Pourquoi des catégories ?** Pour que la vue *Mes tâches* puisse regrouper des tâches venant de projets avec des workflows différents (ex: une tâche "In Dev" de DevKanban et "En cours" de Standard apparaissent dans la même colonne `in_progress`). + +## Changer le workflow d'un projet + +1. Ouvrir le projet → **Modifier le projet** (drawer) +2. Section **Workflow** → cliquer sur **Changer de workflow** +3. Sélectionner le workflow cible +4. **Mapper chaque statut source vers un statut cible** (le mapping est pré-rempli automatiquement par catégorie) +5. **Confirmer** — toutes les tâches migrent dans une seule transaction + +### Règles du mapping + +- ✅ Chaque statut actuellement utilisé par une tâche **doit** être mappé (sinon erreur 422) +- ✅ Un statut peut être mappé vers `null` → la tâche passe en backlog (sans statut) +- ❌ Tu ne peux pas mapper vers un statut qui n'appartient pas au workflow cible + +## Supprimer un workflow + +Tu peux supprimer un workflow uniquement s'il n'est **lié à aucun projet** (HTTP 409 sinon). Réassigne d'abord les projets vers un autre workflow. + +> ⚠️ Le workflow **Standard** ne peut pas être supprimé tant qu'il reste le défaut (un seul workflow peut avoir `isDefault=true` à la fois, garanti par un listener Doctrine). diff --git a/frontend/content/help/03-my-tasks.md b/frontend/content/help/03-my-tasks.md new file mode 100644 index 0000000..9b61a9c --- /dev/null +++ b/frontend/content/help/03-my-tasks.md @@ -0,0 +1,60 @@ +# Mes tâches & Dashboard + +## Vue *Mes tâches* + +Accessible via la sidebar, c'est ta vue **transverse** : toutes les tâches dont tu es l'**assigné** ou un **collaborateur**, peu importe le projet. + +### Deux modes d'affichage + +#### 1. Kanban (par défaut) + +Regroupé par les **5 catégories canoniques** : + +``` +À faire → En cours → Bloqué → En validation → Terminé +``` + +Chaque card affiche : +- Le **code projet + numéro** (ex: `SIRH-12`) coloré selon le projet +- Un **badge statut** (utile quand des tâches de projets différents cohabitent) +- Priorité, tags, deadline, icônes (sync calendrier, récurrence, collaborateurs) +- L'**avatar de l'assigné** + bouton timer (▶ / ⏹) + +> 💡 Le **drag-to-status** est intentionnellement désactivé dans *Mes tâches* — pour changer un statut, ouvre la tâche (la valeur dépend du workflow du projet, pas de la catégorie). + +#### 2. Liste + +Vue tableau triable, avec **bulk actions** : +- Cocher plusieurs tâches → barre d'actions en haut +- Changer statut (désactivé si tâches de **projets différents**), assigné, priorité, effort, groupe +- Supprimer en lot + +### Filtres disponibles + +| Filtre | Notes | +|---|---| +| **Projet** | Restreint à un projet précis | +| **Groupe** | Disponible uniquement si un projet est sélectionné | +| **Tag** | Tags globaux | +| **Priorité / Effort** | | +| **Assigné** | Par défaut : toi-même | + +### Tri (vue liste uniquement) + +- Par **deadline** (les plus proches en premier) +- Par **scheduled start** (planification calendrier) + +## Vue *Backlog* + +Sous le kanban, les tâches **sans statut** apparaissent dans la section *Backlog*. Pratique pour les idées non encore qualifiées. + +## Dashboard + +Le **dashboard** (page d'accueil après login) affiche : + +- 📊 **KPIs personnels** : tâches en cours / à faire / en retard +- 📈 **Charts** : répartition par statut, par priorité, time tracking cette semaine +- 🔔 **Notifications** : assignations, commentaires (cf. cloche en topbar) +- ⏱ **Timer actif** s'il y en a un + +> 💡 Tu peux changer le filtre user du dashboard via le sélecteur en haut pour voir les KPIs d'un collègue (utile pour les leads). diff --git a/frontend/content/help/04-time-tracking.md b/frontend/content/help/04-time-tracking.md new file mode 100644 index 0000000..21f04c6 --- /dev/null +++ b/frontend/content/help/04-time-tracking.md @@ -0,0 +1,59 @@ +# Time tracking + +## Le timer + +Le timer **flottant** est accessible depuis la sidebar ou directement depuis une tâche. + +### Démarrer un timer + +Trois façons : + +1. **Depuis une TaskCard** : clique sur l'icône ▶ à droite de la card +2. **Depuis le détail d'une tâche** : bouton *Démarrer le timer* +3. **Manuellement** : depuis */time-tracking*, créer une time entry sans tâche + +### Arrêter + +- Clique sur ⏹ sur la card de la tâche en cours +- Ou depuis la sidebar (icône timer pulsante en orange `#F18619`) + +> 💡 Un seul timer actif à la fois. Démarrer un nouveau timer arrête automatiquement le précédent. + +## Time entries + +Chaque entrée a : + +| Champ | Description | +|---|---| +| **Titre** | Description courte (ex: "Réunion daily") | +| **Projet** | Obligatoire | +| **Tâche** | Optionnel — lie l'entrée à une tâche précise | +| **Tags** | Pour catégoriser (ex: "Backend", "Réunion") | +| **Début / Fin** | Datetimes — la durée est calculée | +| **User** | Qui a fait le travail | + +### Vue *Time tracking* + +Disponible en deux modes : + +- **Vue semaine** : ligne par ligne, par jour +- **Vue mois** : agrégation mensuelle, totaux par projet et par tag + +### Filtres + +- **Projet** (server-side) +- **Tag** (server-side) +- **User** (admin uniquement) +- **Période** (date début / date fin) + +## Édition + +- Clique sur une time entry → drawer d'édition +- Tu peux modifier projet, tâche, tags, dates a posteriori +- La suppression est libre — pense à exporter avant si nécessaire + +## Tags + +Les tags sont **globaux** (partagés entre tous les projets, comme les statuts l'étaient avant les workflows). Définis depuis **Admin → Tags**. + +> 📊 **Cas d'usage typique** : créer un tag par typologie d'activité (Dev, Réunion, Support, Veille) pour pouvoir agréger ton temps en fin de mois. diff --git a/frontend/content/help/05-tasks-detail.md b/frontend/content/help/05-tasks-detail.md new file mode 100644 index 0000000..9010804 --- /dev/null +++ b/frontend/content/help/05-tasks-detail.md @@ -0,0 +1,62 @@ +# Détail d'une tâche + +## Champs principaux + +| Champ | Notes | +|---|---| +| **Numéro** | Auto-incrémenté **par projet** (ex: `SIRH-1`, `SIRH-2`, `CRM-1`…) | +| **Titre** | Obligatoire | +| **Description** | Markdown supporté (preview disponible) | +| **Statut** | Doit appartenir au workflow du projet (sinon erreur 422) | +| **Priorité** | Basse / Moyenne / Haute — couleurs personnalisables | +| **Effort** | S / M / L / XL / XXL — pour estimer la charge | +| **Assigné** | Un seul user responsable | +| **Collaborateurs** | Multiples — visibles via icône `mdi:account-group` | +| **Groupe** | Optionnel — regroupe des tâches au sein d'un projet | +| **Tags** | Globaux, plusieurs par tâche | +| **Deadline** | Date — un badge coloré apparaît sur la card | +| **Scheduled start / end** | Planification calendrier (sync optionnelle) | + +## Récurrence + +Une tâche peut être **récurrente** (icône 🔁 sur la card) : + +- **Type** : quotidien, hebdomadaire, mensuel +- **Intervalle** : tous les N jours/semaines/mois +- **Jours de la semaine** (pour le mode hebdomadaire) : `monday`, `tuesday`, etc. + +Chaque occurrence est gérée séparément ; cocher une tâche récurrente comme *Terminée* peut générer l'occurrence suivante selon le pattern. + +## Sync calendrier + +Si Zimbra est configuré (cf. Intégrations), tu peux activer **Sync calendrier** sur une tâche planifiée pour qu'elle apparaisse dans ton calendrier Zimbra (CalDav). + +Icônes correspondantes : +- 🟢 `mdi:calendar-check` → sync OK +- 🔴 `mdi:alert-circle` → erreur de sync (passe sur l'icône pour le détail) + +## Documents + +Chaque tâche peut avoir des **documents attachés** (PDF, images, etc.) : + +- Drag & drop dans la tâche pour uploader +- Validation du **MIME type côté serveur** (pas seulement l'extension) +- Téléchargement via lien dédié + +## Liaison Gitea (si configuré) + +Si le projet a un repo Gitea lié, tu peux : + +- **Créer une branche** depuis la tâche : `feature/` `fix/` `refactor/` `hotfix/` `chore/` (5 types disponibles) +- Convention de nommage : `/--` (ex: `feature/SIRH-12-add-login`) +- **Voir les PRs** liées (état CI inclus) + +## Liaison ticket client + +Si la tâche découle d'un ticket client, l'icône 👤 (`heroicons:user-circle`) bleue apparaît avec le numéro du ticket (ex: `CT-001`). + +## Commentaires & notifications + +- Ajouter un commentaire notifie les watchers (assigné, collaborateurs) +- Les @mentions notifient l'utilisateur cité +- La cloche en topbar (`NotificationBell`) liste toutes les notifications non lues diff --git a/frontend/content/help/06-client-portal.md b/frontend/content/help/06-client-portal.md new file mode 100644 index 0000000..4bc0dce --- /dev/null +++ b/frontend/content/help/06-client-portal.md @@ -0,0 +1,43 @@ +# Portal client + +> 🎫 Section dédiée aux utilisateurs avec le rôle **ROLE_CLIENT**. + +## Accès + +Les utilisateurs *client* sont **automatiquement redirigés vers `/portal`** après login. Ils ne voient pas les vues internes (projets, time tracking, admin). + +## Ce que voit un client + +- 📋 La liste de ses **projets autorisés** (définis par l'admin dans le user) +- 🎫 Sur chaque projet, la liste de ses **tickets** (ses créations uniquement) +- ➕ Le bouton **Nouveau ticket** sur chaque projet + +## Soumettre un ticket + +Depuis `/portal/projects//new-ticket` : + +| Champ | Description | +|---|---| +| **Type** | `bug` / `improvement` / `other` | +| **Titre** | Court et descriptif | +| **Description** | Détails — markdown supporté | +| **URL** | Optionnel — page où le problème se manifeste | + +Le ticket est automatiquement numéroté **par projet** (ex: `CT-001`). + +## Statuts d'un ticket + +| Statut | Visible côté client | Signification | +|---|---|---| +| `new` | Oui | Reçu, pas encore traité | +| `in_progress` | Oui | Une tâche interne y est liée | +| `done` | Oui | Résolu et clôturé | +| `rejected` | Oui | Non retenu (avec commentaire explicatif) | + +Le `statusComment` est visible par le client quand fourni. + +## Côté équipe interne + +- Les tickets apparaissent dans **Admin → Tickets client** +- On peut **transformer un ticket en tâche** (la tâche garde une référence au ticket — icône 👤 bleue sur la card) +- Le client voit l'avancement passer en `in_progress` automatiquement quand une tâche est liée diff --git a/frontend/content/help/07-admin.md b/frontend/content/help/07-admin.md new file mode 100644 index 0000000..1f00d0b --- /dev/null +++ b/frontend/content/help/07-admin.md @@ -0,0 +1,66 @@ +# Administration + +> 🛡️ Section visible uniquement par les utilisateurs **ROLE_ADMIN**. + +L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressource globale ou une intégration. + +## Onglet *Clients* + +- Liste des clients (entreprise / organisation) +- Champs : nom, email, téléphone, adresse +- Lier un client à des projets + +## Onglet *Workflows* + +⭐ **Nouveau** — remplace l'ancien onglet *Statuts*. + +- Lister les workflows existants +- **Créer un workflow** : nom, isDefault (un seul à la fois), liste de statuts éditables inline +- Chaque statut : libellé, couleur, position, **catégorie** (5 valeurs canoniques), isFinal +- **Éditer** un workflow modifie les statuts (sync intelligent : create / update / delete par diff) + +> ⚠️ Supprimer un workflow lié à un projet renvoie une erreur **409**. Réassigne d'abord les projets. + +## Onglet *Efforts* + +- Tailles d'effort (S, M, L, XL, XXL) +- Globales (partagées entre tous les projets) + +## Onglet *Priorités* + +- Niveaux de priorité (Basse, Moyenne, Haute) + couleur +- Une priorité "Haute" affiche un drapeau rouge `mdi:flag-variant` sur la card + +## Onglet *Tags* + +- Tags globaux (tâches **et** time entries) +- Couleur personnalisable +- Pas de hiérarchie (flat list) + +## Onglet *Utilisateurs* + +- Créer / éditer / désactiver +- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT` +- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés* +- Reset password depuis l'admin + +> 🔐 Un user *admin+client* (les deux rôles) **n'est pas bloqué** par le middleware portal — le check est sur `ROLE_CLIENT && !ROLE_ADMIN`. + +## Onglet *Gitea* + +- URL serveur + token API +- Lier un projet à un repo : `giteaOwner` + `giteaRepo` +- Active les fonctionnalités branches / PRs sur les tâches + +## Onglet *BookStack* + +- URL + token API +- Lier un projet à un **shelf** BookStack (`bookstackShelfId`) +- Les tâches peuvent être liées à des pages BookStack (cf. `TaskBookStackLink`) + +## Onglet *Zimbra* + +- URL serveur + credentials (chiffrés via libsodium) +- Configure le calendrier CalDav par défaut +- Test de connexion intégré +- Active la **sync calendrier** sur les tâches planifiées diff --git a/frontend/content/help/08-integrations.md b/frontend/content/help/08-integrations.md new file mode 100644 index 0000000..fa008f9 --- /dev/null +++ b/frontend/content/help/08-integrations.md @@ -0,0 +1,66 @@ +# Intégrations + +Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev. + +## 🌳 Gitea + +Lesstime parle à un serveur Gitea pour automatiser les conventions de branches et suivre les PRs. + +### Configuration + +1. **Admin → Gitea** : URL serveur + token API +2. Sur un projet : définir `giteaOwner` (org/user) et `giteaRepo` (nom du repo) + +### Utilisation + +Sur une tâche, le panneau Gitea propose : + +- **Créer une branche** : choisir un type (`feature` / `fix` / `refactor` / `hotfix` / `chore`) +- La branche est nommée automatiquement : `/--` +- **Lister les PRs liées** : par convention, toute PR qui contient `-` dans son nom ou sa description est reliée +- **État CI** : ✅ ou ❌ affiché si le repo a des Actions/Workflows configurées + +> 💡 La convention `-` permet à Gitea et Lesstime de se synchroniser **sans webhook** — juste par parsing des noms. + +## 📚 BookStack + +Lien tâche → documentation. + +### Configuration + +1. **Admin → BookStack** : URL + token (token ID + token secret, chiffrés via libsodium) +2. Sur un projet : définir `bookstackShelfId` + `bookstackShelfName` + +### Utilisation + +- Depuis une tâche : bouton **Lier à une page BookStack** +- Sélectionner la page dans le shelf du projet +- Le lien est bidirectionnel (BookStack peut afficher les tâches liées) + +## 📅 Zimbra (CalDav) + +Sync calendrier pour les tâches planifiées. + +### Configuration + +1. **Admin → Zimbra** : + - URL serveur (ex: `https://mail.ovh.com`) + - Username (ex: `lesstime@ovh.fr`) + - Password (chiffré côté serveur) + - Calendar path (ex: `/dav/lesstime@ovh.fr/Calendar/`) + - **Test de connexion** intégré +2. Active la config (toggle `enabled`) + +### Utilisation + +Sur une tâche avec **scheduled start + end** : + +1. Cocher **Sync calendrier** +2. Au save, Lesstime crée/met à jour l'événement CalDav +3. L'icône `mdi:calendar-check` (verte) apparaît sur la card si succès +4. L'icône `mdi:alert-circle` (rouge) apparaît si erreur — passe dessus pour voir le détail + +### Limites + +- **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas +- **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly) diff --git a/frontend/content/help/09-mcp-api.md b/frontend/content/help/09-mcp-api.md new file mode 100644 index 0000000..703c4ac --- /dev/null +++ b/frontend/content/help/09-mcp-api.md @@ -0,0 +1,97 @@ +# Token MCP & API + +Lesstime expose un serveur **MCP** (Model Context Protocol) qui permet à un assistant IA (Claude, Cursor, etc.) de piloter ton instance Lesstime — créer des tâches, lire des projets, démarrer un timer, etc. + +## Générer ton token + +1. Va sur **Profil** (avatar → Profil) +2. Section **Token MCP** → **Générer un token** +3. **Copie le token immédiatement** — il ne sera plus affiché ensuite + +> 🔐 **Sécurité** : Le token donne accès à toutes les actions de ton compte. Ne le partage jamais. Tu peux le régénérer à tout moment (l'ancien sera révoqué). + +## Configurer Claude Code + +Dans `.mcp.json` (à la racine de ton projet) : + +```json +{ + "mcpServers": { + "lesstime": { + "type": "http", + "url": "https://ton-instance-lesstime/_mcp", + "headers": { + "Authorization": "Bearer TON_TOKEN_ICI" + } + } + } +} +``` + +Pour une instance locale : + +```json +{ + "mcpServers": { + "lesstime-local": { + "command": "docker", + "args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"] + } + } +} +``` + +## Tools disponibles (27 au total) + +### Projets + +- `list-projects`, `get-project`, `create-project`, `update-project` + +### Tâches + +- `list-tasks` (avec filtres : projet, assigné, statut, archived…) +- `get-task`, `create-task`, `update-task`, `delete-task` + +### Métadonnées + +- `list-statuses` (param **`projectId`** optionnel — sans : tous les statuts ; avec : statuts du workflow du projet) +- `list-priorities`, `list-efforts`, `list-tags` + +### Workflows ⭐ Nouveau + +- `list-workflows` — liste tous les workflows avec leurs statuts groupés +- `switch-project-workflow` (ROLE_ADMIN) — change le workflow d'un projet avec mapping + +### Time tracking + +- `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` + +### Récurrence + +- `create-task-recurrence`, `update-task-recurrence`, `delete-task-recurrence` + +### Groupes / Users / Clients + +- `list-groups`, `create-group`, `update-group` +- `list-users`, `list-clients` + +## Règles importantes + +> ⚠️ **Statut hors workflow rejeté** : si tu appelles `create-task` ou `update-task` avec un `status` qui n'appartient pas au workflow du projet, l'appel est rejeté avec **422 Validation error**. Utilise `list-statuses(projectId)` pour découvrir les statuts valides du projet. + +## Exemples de prompts + +``` +"Crée une tâche dans Lesstime sur le projet SIRH avec le titre +'Ajouter l'export PDF' et la priorité Haute, assignée à alice" +``` + +``` +"Liste mes tâches en cours dans le projet CRM" +``` + +``` +"Démarre un timer sur la tâche SIRH-12 avec le tag Backend" +``` + +L'agent appelle les bons tools tout seul si la description est claire. diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index d6d469f..7bd9f6c 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -56,6 +56,37 @@ "moveTo": "Déplacer vers", "backlog": "Backlog (sans statut)" }, + "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é" + } + }, "taskEfforts": { "created": "Effort créé avec succès.", "updated": "Effort mis à jour avec succès.", diff --git a/frontend/pages/admin.vue b/frontend/pages/admin.vue index 79cd2ca..7ad5831 100644 --- a/frontend/pages/admin.vue +++ b/frontend/pages/admin.vue @@ -22,7 +22,7 @@
- + @@ -41,7 +41,7 @@ useHead({ title: 'Administration' }) const tabs = [ { key: 'clients', label: 'Clients' }, - { key: 'statuses', label: 'Statuts' }, + { key: 'workflows', label: 'Workflows' }, { key: 'efforts', label: 'Efforts' }, { key: 'priorities', label: 'Priorités' }, { key: 'tags', label: 'Tags' }, diff --git a/frontend/pages/help.vue b/frontend/pages/help.vue new file mode 100644 index 0000000..24c12c0 --- /dev/null +++ b/frontend/pages/help.vue @@ -0,0 +1,168 @@ + + + diff --git a/frontend/pages/my-tasks.vue b/frontend/pages/my-tasks.vue index ce36c08..cece4ab 100644 --- a/frontend/pages/my-tasks.vue +++ b/frontend/pages/my-tasks.vue @@ -7,6 +7,8 @@ import type { TaskTag } from '~/services/dto/task-tag' import type { TaskGroup } from '~/services/dto/task-group' import type { UserData } from '~/services/dto/user-data' import type { Project } from '~/services/dto/project' +import type { StatusCategory } from '~/services/dto/workflow' +import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow' import { useTaskService } from '~/services/tasks' import { useTaskStatusService } from '~/services/task-statuses' import { useTaskEffortService } from '~/services/task-efforts' @@ -60,6 +62,7 @@ const viewMode = ref<'kanban' | 'list'>('kanban') // Bulk selection const selectedTaskIds = reactive(new Set()) +const selectedTasksArray = computed(() => tasks.value.filter(t => selectedTaskIds.has(t.id))) // Modal const taskModalOpen = ref(false) @@ -112,13 +115,11 @@ const sortOptions = computed(() => [ { label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED }, ]) -// Kanban helpers -const sortedStatuses = computed(() => - [...statuses.value].sort((a, b) => a.position - b.position) -) +// Kanban helpers (grouped by canonical status category) +const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done'] -function tasksByStatus(statusId: number): Task[] { - return tasks.value.filter(t => t.status?.id === statusId) +function tasksByCategory(category: StatusCategory): Task[] { + return tasks.value.filter(t => t.status?.category === category) } const backlogTasks = computed(() => @@ -205,44 +206,6 @@ watch(selectedProjectId, () => { selectedGroupId.value = null }, { flush: 'sync' }) -// Drag & drop -const dragOverStatusId = ref(null) -const dragCounter = ref(0) - -function onDragEnter(id: number) { - dragCounter.value++ - dragOverStatusId.value = id -} - -function onDragLeave() { - dragCounter.value-- - if (dragCounter.value === 0) { - dragOverStatusId.value = null - } -} - -function onDrop(event: DragEvent) { - dragCounter.value = 0 - dragOverStatusId.value = null - return Number(event.dataTransfer!.getData('text/plain')) -} - -async function onDropStatus(event: DragEvent, status: TaskStatus) { - const taskId = onDrop(event) - const task = tasks.value.find(t => t.id === taskId) - if (!task || task.status?.id === status.id) return - task.status = status - await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` }) -} - -async function onDropBacklog(event: DragEvent) { - const taskId = onDrop(event) - const task = tasks.value.find(t => t.id === taskId) - if (!task || !task.status) return - task.status = null - await taskService.update(taskId, { status: null }) -} - // Modal function openTaskCreate() { selectedTask.value = null @@ -428,36 +391,29 @@ onMounted(async () => {
- +
-
- {{ status.label }} ({{ tasksByStatus(status.id).length }}) +
+ {{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})

{{ $t('myTasks.noTasks') }} @@ -467,15 +423,8 @@ onMounted(async () => {

- -
+ +

{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})

{ :key="task.id" :task="task" show-project-color + show-status-badge @click="openTaskEdit(task)" />
@@ -507,6 +457,8 @@ onMounted(async () => { :priorities="priorities" :efforts="efforts" :groups="groups" + :selected-tasks="selectedTasksArray" + :projects="projects" @toggle-all="toggleSelectAll(tasks)" @bulk-update="onBulkUpdate" @bulk-archive="onBulkArchive" diff --git a/frontend/pages/projects/[id]/archives.vue b/frontend/pages/projects/[id]/archives.vue index 703bc82..e41df4a 100644 --- a/frontend/pages/projects/[id]/archives.vue +++ b/frontend/pages/projects/[id]/archives.vue @@ -82,7 +82,6 @@ import type { TaskGroup } from '~/services/dto/task-group' import type { UserData } from '~/services/dto/user-data' import { useProjectService } from '~/services/projects' import { useTaskService } from '~/services/tasks' -import { useTaskStatusService } from '~/services/task-statuses' import { useTaskEffortService } from '~/services/task-efforts' import { useTaskPriorityService } from '~/services/task-priorities' import { useTaskTagService } from '~/services/task-tags' @@ -96,7 +95,6 @@ useHead({ title: 'Archives' }) const projectService = useProjectService() const taskService = useTaskService() -const statusService = useTaskStatusService() const effortService = useTaskEffortService() const priorityService = useTaskPriorityService() const tagService = useTaskTagService() @@ -105,8 +103,11 @@ const userService = useUserService() const project = ref(null) const archivedTasks = ref([]) -const statuses = ref([]) const efforts = ref([]) + +const statuses = computed(() => + [...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position), +) const priorities = ref([]) const tags = ref([]) const groups = ref([]) @@ -126,10 +127,9 @@ const filteredTasks = computed(() => { }) async function loadData() { - const [p, t, s, e, pr, ty, g, u] = await Promise.all([ + const [p, t, e, pr, ty, g, u] = await Promise.all([ projectService.getById(projectId.value), taskService.getByProject(projectId.value, true), - statusService.getAll(), effortService.getAll(), priorityService.getAll(), tagService.getAll(), @@ -138,7 +138,6 @@ async function loadData() { ]) project.value = p archivedTasks.value = t - statuses.value = s efforts.value = e priorities.value = pr tags.value = ty diff --git a/frontend/pages/projects/[id]/index.vue b/frontend/pages/projects/[id]/index.vue index 45cd70d..10490ab 100644 --- a/frontend/pages/projects/[id]/index.vue +++ b/frontend/pages/projects/[id]/index.vue @@ -218,7 +218,6 @@ import type { Client } from '~/services/dto/client' import { useProjectService } from '~/services/projects' import { useClientService } from '~/services/clients' import { useTaskService } from '~/services/tasks' -import { useTaskStatusService } from '~/services/task-statuses' import { useTaskEffortService } from '~/services/task-efforts' import { useTaskPriorityService } from '~/services/task-priorities' import { useTaskTagService } from '~/services/task-tags' @@ -234,7 +233,6 @@ useHead({ title: 'Projet' }) const projectService = useProjectService() const clientService = useClientService() const taskService = useTaskService() -const statusService = useTaskStatusService() const effortService = useTaskEffortService() const priorityService = useTaskPriorityService() const tagService = useTaskTagService() @@ -243,7 +241,6 @@ const userService = useUserService() const project = ref(null) const tasks = ref([]) -const statuses = ref([]) const efforts = ref([]) const priorities = ref([]) const tags = ref([]) @@ -252,6 +249,10 @@ const users = ref([]) const clients = ref([]) const isLoading = ref(true) +const statuses = computed(() => + [...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position), +) + const selectedGroupId = ref(null) const selectedTagId = ref(null) const selectedAssigneeId = ref(null) @@ -333,10 +334,9 @@ const backlogTasks = computed(() => async function loadData() { isLoading.value = true try { - const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([ + const [p, t, e, pr, ty, g, u, c] = await Promise.all([ projectService.getById(projectId.value), taskService.getByProject(projectId.value), - statusService.getAll(), effortService.getAll(), priorityService.getAll(), tagService.getAll(), @@ -346,7 +346,6 @@ async function loadData() { ]) project.value = p tasks.value = t - statuses.value = s efforts.value = e priorities.value = pr tags.value = ty diff --git a/frontend/services/dto/project.ts b/frontend/services/dto/project.ts index 10cbb3c..8e36770 100644 --- a/frontend/services/dto/project.ts +++ b/frontend/services/dto/project.ts @@ -1,4 +1,5 @@ import type { Client } from './client' +import type { Workflow } from './workflow' export type Project = { id: number @@ -8,6 +9,7 @@ export type Project = { description: string | null color: string client: Client | null + workflow: Workflow giteaOwner: string | null giteaRepo: string | null bookstackShelfId: number | null @@ -22,6 +24,7 @@ export type ProjectWrite = { description: string | null color: string client: string | null // IRI : "/api/clients/1" ou null + workflow?: string // IRI : "/api/workflows/1" giteaOwner?: string | null giteaRepo?: string | null bookstackShelfId?: number | null diff --git a/frontend/services/dto/task-status.ts b/frontend/services/dto/task-status.ts index 0b2de86..610d3b5 100644 --- a/frontend/services/dto/task-status.ts +++ b/frontend/services/dto/task-status.ts @@ -1,3 +1,5 @@ +import type { StatusCategory } from './workflow' + export type TaskStatus = { id: number '@id'?: string @@ -5,6 +7,8 @@ export type TaskStatus = { color: string position: number isFinal: boolean + category: StatusCategory + workflow?: { '@id': string, id: number } | string } export type TaskStatusWrite = { @@ -12,4 +16,6 @@ export type TaskStatusWrite = { color: string position: number isFinal: boolean + category: StatusCategory + workflow?: string } diff --git a/frontend/services/dto/workflow.ts b/frontend/services/dto/workflow.ts new file mode 100644 index 0000000..783dd1f --- /dev/null +++ b/frontend/services/dto/workflow.ts @@ -0,0 +1,27 @@ +import type { TaskStatus, TaskStatusWrite } from './task-status' + +export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done' + +export const STATUS_CATEGORY_LABEL: Record = { + todo: 'À faire', + in_progress: 'En cours', + blocked: 'Bloqué', + review: 'En validation', + done: 'Terminé', +} + +export type Workflow = { + id: number + '@id'?: string + name: string + isDefault: boolean + position: number + statuses: TaskStatus[] +} + +export type WorkflowWrite = { + name: string + isDefault: boolean + position: number + statuses?: TaskStatusWrite[] +} diff --git a/frontend/services/workflows.ts b/frontend/services/workflows.ts new file mode 100644 index 0000000..1fd86cc --- /dev/null +++ b/frontend/services/workflows.ts @@ -0,0 +1,55 @@ +import type { Workflow, WorkflowWrite } from './dto/workflow' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +type SwitchPayload = { + workflowId: number + mapping: Record +} + +type SwitchResult = { + projectId: number + workflowId: number + migratedTaskCount: number +} + +export function useWorkflowService() { + const api = useApi() + + async function getAll(): Promise { + const data = await api.get>('/workflows') + return extractHydraMembers(data) + } + + async function getOne(id: number): Promise { + return api.get(`/workflows/${id}`) + } + + async function create(payload: WorkflowWrite): Promise { + return api.post('/workflows', payload as Record, { + toastSuccessKey: 'workflows.created', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/workflows/${id}`, payload as Record, { + toastSuccessKey: 'workflows.updated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/workflows/${id}`, {}, { + toastSuccessKey: 'workflows.deleted', + }) + } + + async function switchOnProject(projectId: number, payload: SwitchPayload): Promise { + return api.post( + `/projects/${projectId}/switch-workflow`, + payload as unknown as Record, + { toastSuccessKey: 'workflows.switched' }, + ) + } + + return { getAll, getOne, create, update, remove, switchOnProject } +} diff --git a/migrations/Version20260519175041.php b/migrations/Version20260519175041.php new file mode 100644 index 0000000..f8ecaa8 --- /dev/null +++ b/migrations/Version20260519175041.php @@ -0,0 +1,36 @@ +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'); + } +} diff --git a/migrations/Version20260519175114.php b/migrations/Version20260519175114.php new file mode 100644 index 0000000..29046bb --- /dev/null +++ b/migrations/Version20260519175114.php @@ -0,0 +1,74 @@ +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'); + } +} diff --git a/migrations/Version20260519175142.php b/migrations/Version20260519175142.php new file mode 100644 index 0000000..6f89e01 --- /dev/null +++ b/migrations/Version20260519175142.php @@ -0,0 +1,38 @@ +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'); + } +} diff --git a/migrations/Version20260519175338.php b/migrations/Version20260519175338.php new file mode 100644 index 0000000..244b782 --- /dev/null +++ b/migrations/Version20260519175338.php @@ -0,0 +1,38 @@ +addSql('ALTER INDEX idx_project_workflow RENAME TO IDX_2FB3D0EE2C7C2CBA'); + $this->addSql('ALTER INDEX idx_task_status_workflow RENAME TO IDX_40A9E1CF2C7C2CBA'); + $this->addSql('DROP INDEX uniq_workflow_one_default'); + $this->addSql('ALTER TABLE workflow ALTER id DROP DEFAULT'); + $this->addSql('ALTER TABLE workflow ALTER id ADD GENERATED BY DEFAULT AS IDENTITY'); + // Aligner la séquence d'identity sur MAX(id) pour éviter le conflit avec les rows déjà insérés par M1 + $this->addSql('SELECT setval(pg_get_serial_sequence(\'workflow\', \'id\'), COALESCE((SELECT MAX(id) FROM workflow), 0) + 1, false)'); + $this->addSql('ALTER INDEX uniq_workflow_name RENAME TO UNIQ_65C598165E237E06'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER INDEX idx_2fb3d0ee2c7c2cba RENAME TO idx_project_workflow'); + $this->addSql('ALTER INDEX idx_40a9e1cf2c7c2cba RENAME TO idx_task_status_workflow'); + $this->addSql('ALTER TABLE workflow ALTER id DROP IDENTITY'); + $this->addSql("ALTER TABLE workflow ALTER id SET DEFAULT nextval('workflow_id_seq'::regclass)"); + $this->addSql('CREATE UNIQUE INDEX uniq_workflow_one_default ON workflow (is_default) WHERE (is_default = true)'); + $this->addSql('ALTER INDEX uniq_65c598165e237e06 RENAME TO uniq_workflow_name'); + } +} diff --git a/src/ApiResource/SwitchWorkflowOutput.php b/src/ApiResource/SwitchWorkflowOutput.php new file mode 100644 index 0000000..ddc3133 --- /dev/null +++ b/src/ApiResource/SwitchWorkflowOutput.php @@ -0,0 +1,26 @@ +projectId = $projectId; + $this->workflowId = $workflowId; + $this->migratedTaskCount = $migratedTaskCount; + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index e2e9296..a6a1c03 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -17,8 +17,10 @@ use App\Entity\TaskStatus; use App\Entity\TaskTag; use App\Entity\TimeEntry; use App\Entity\User; +use App\Entity\Workflow; use App\Entity\ZimbraConfiguration; use App\Enum\RecurrenceType; +use App\Enum\StatusCategory; use DateTimeImmutable; use DateTimeZone; use Doctrine\Bundle\FixturesBundle\Fixture; @@ -87,57 +89,31 @@ class AppFixtures extends Fixture $clientNova->setPostalCode('69007'); $manager->persist($clientNova); - // Projets - $projectSirh = new Project(); - $projectSirh->setCode('SIRH'); - $projectSirh->setName('SIRH'); - $projectSirh->setDescription('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer ac blandit turpis.'); - $projectSirh->setColor('#222783'); - $projectSirh->setClient($clientLiot); - $manager->persist($projectSirh); + // Workflow par défaut + $standardWorkflow = new Workflow(); + $standardWorkflow->setName('Standard'); + $standardWorkflow->setIsDefault(true); + $standardWorkflow->setPosition(0); + $manager->persist($standardWorkflow); - $projectCrm = new Project(); - $projectCrm->setCode('CRM'); - $projectCrm->setName('CRM'); - $projectCrm->setDescription('Gestion de la relation client et suivi commercial.'); - $projectCrm->setColor('#E91E63'); - $projectCrm->setClient($clientAcme); - $manager->persist($projectCrm); - - $projectErp = new Project(); - $projectErp->setCode('ERP'); - $projectErp->setName('ERP'); - $projectErp->setDescription('Planification des ressources et gestion des stocks.'); - $projectErp->setColor('#4A90D9'); - $projectErp->setClient($clientNova); - $manager->persist($projectErp); - - $projectInterne = new Project(); - $projectInterne->setCode('SITE'); - $projectInterne->setName('Site vitrine'); - $projectInterne->setDescription('Refonte du site web corporate.'); - $projectInterne->setColor('#26A69A'); - $projectInterne->setClient(null); - $manager->persist($projectInterne); - - // Task Statuses (global) + // Task Statuses (rattachés au workflow Standard) $defaultStatuses = [ - ['A faire', '#222783', 0], - ['En cours', '#4A90D9', 1], - ['Bloqué', '#C62828', 2], - ['En attente de validation', '#FF8F00', 3], - ['Terminé', '#26A69A', 4], + ['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]) { + foreach ($defaultStatuses as [$label, $color, $position, $category, $isFinal]) { $status = new TaskStatus(); $status->setLabel($label); $status->setColor($color); $status->setPosition($position); - if ('Terminé' === $label) { - $status->setIsFinal(true); - } + $status->setCategory($category); + $status->setIsFinal($isFinal); + $standardWorkflow->addStatus($status); $manager->persist($status); $statusObjects[$label] = $status; } @@ -148,6 +124,43 @@ class AppFixtures extends Fixture $statusReview = $statusObjects['En attente de validation']; $statusDone = $statusObjects['Terminé']; + // Projets + $projectSirh = new Project(); + $projectSirh->setCode('SIRH'); + $projectSirh->setName('SIRH'); + $projectSirh->setDescription('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer ac blandit turpis.'); + $projectSirh->setColor('#222783'); + $projectSirh->setClient($clientLiot); + $projectSirh->setWorkflow($standardWorkflow); + $manager->persist($projectSirh); + + $projectCrm = new Project(); + $projectCrm->setCode('CRM'); + $projectCrm->setName('CRM'); + $projectCrm->setDescription('Gestion de la relation client et suivi commercial.'); + $projectCrm->setColor('#E91E63'); + $projectCrm->setClient($clientAcme); + $projectCrm->setWorkflow($standardWorkflow); + $manager->persist($projectCrm); + + $projectErp = new Project(); + $projectErp->setCode('ERP'); + $projectErp->setName('ERP'); + $projectErp->setDescription('Planification des ressources et gestion des stocks.'); + $projectErp->setColor('#4A90D9'); + $projectErp->setClient($clientNova); + $projectErp->setWorkflow($standardWorkflow); + $manager->persist($projectErp); + + $projectInterne = new Project(); + $projectInterne->setCode('SITE'); + $projectInterne->setName('Site vitrine'); + $projectInterne->setDescription('Refonte du site web corporate.'); + $projectInterne->setColor('#26A69A'); + $projectInterne->setClient(null); + $projectInterne->setWorkflow($standardWorkflow); + $manager->persist($projectInterne); + // Task Efforts $effortS = new TaskEffort(); $effortS->setLabel('S'); diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 6a5afcd..04ce728 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -10,9 +10,12 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; +use App\ApiResource\SwitchWorkflowOutput; use App\Repository\ProjectRepository; +use App\State\SwitchProjectWorkflowProcessor; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -30,6 +33,19 @@ use Symfony\Component\Validator\Constraints as Assert; ), new Patch(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')"), + 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, + name: 'switch_workflow', + ), ], normalizationContext: ['groups' => ['project:read']], denormalizationContext: ['groups' => ['project:write']], @@ -69,6 +85,12 @@ class Project #[Groups(['project:read', 'project:write'])] private ?Client $client = null; + #[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; + #[ORM\Column(length: 255, nullable: true)] #[Groups(['project:read', 'project:write', 'task:read'])] private ?string $giteaOwner = null; @@ -228,6 +250,18 @@ class Project return $this; } + public function getWorkflow(): ?Workflow + { + return $this->workflow; + } + + public function setWorkflow(Workflow $workflow): static + { + $this->workflow = $workflow; + + return $this; + } + #[Groups(['project:read'])] public function getTaskCount(): int { diff --git a/src/Entity/Task.php b/src/Entity/Task.php index 639b929..486371f 100644 --- a/src/Entity/Task.php +++ b/src/Entity/Task.php @@ -478,4 +478,26 @@ class Task ; } } + + #[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; + } + + if ($projectWorkflow->getId() !== $statusWorkflow->getId()) { + $context->buildViolation('Status does not belong to this project\'s workflow.') + ->atPath('status') + ->addViolation() + ; + } + } } diff --git a/src/Entity/TaskStatus.php b/src/Entity/TaskStatus.php index 0d3d18b..aa69787 100644 --- a/src/Entity/TaskStatus.php +++ b/src/Entity/TaskStatus.php @@ -10,9 +10,11 @@ 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: [ @@ -32,25 +34,36 @@ class TaskStatus #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['task_status:read', 'task:read'])] + #[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'])] + #[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'])] + #[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'])] + #[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'])] + #[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; @@ -103,4 +116,28 @@ class TaskStatus 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; + } } diff --git a/src/Entity/Workflow.php b/src/Entity/Workflow.php new file mode 100644 index 0000000..80db9b3 --- /dev/null +++ b/src/Entity/Workflow.php @@ -0,0 +1,131 @@ + ['workflow:read']], + denormalizationContext: ['groups' => ['workflow:write']], + order: ['position' => 'ASC'], +)] +#[ORM\Entity(repositoryClass: WorkflowRepository::class)] +#[UniqueEntity(fields: ['name'], message: 'Ce nom de workflow est déjà utilisé.')] +class Workflow +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['workflow:read', 'project:read', 'task_status:read'])] + private ?int $id = null; + + #[ORM\Column(length: 255, unique: true)] + #[Groups(['workflow:read', 'workflow:write', 'project:read'])] + #[Assert\NotBlank] + private ?string $name = null; + + #[ORM\Column(type: 'boolean', options: ['default' => false])] + #[Groups(['workflow:read', 'workflow:write'])] + private bool $isDefault = false; + + #[ORM\Column(type: 'integer', options: ['default' => 0])] + #[Groups(['workflow:read', 'workflow:write'])] + private int $position = 0; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: TaskStatus::class, mappedBy: 'workflow', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['position' => 'ASC'])] + #[Groups(['workflow:read', 'project:read'])] + private Collection $statuses; + + public function __construct() + { + $this->statuses = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function isDefault(): bool + { + return $this->isDefault; + } + + public function setIsDefault(bool $isDefault): static + { + $this->isDefault = $isDefault; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } + + /** @return Collection */ + public function getStatuses(): Collection + { + return $this->statuses; + } + + public function addStatus(TaskStatus $status): static + { + if (!$this->statuses->contains($status)) { + $this->statuses->add($status); + $status->setWorkflow($this); + } + + return $this; + } + + public function removeStatus(TaskStatus $status): static + { + $this->statuses->removeElement($status); + + return $this; + } +} diff --git a/src/Enum/StatusCategory.php b/src/Enum/StatusCategory.php new file mode 100644 index 0000000..c9eaef5 --- /dev/null +++ b/src/Enum/StatusCategory.php @@ -0,0 +1,14 @@ +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 (0 === count($candidates)) { + return; + } + + $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); + } + } +} diff --git a/src/Mcp/Tool/Task/CreateTaskTool.php b/src/Mcp/Tool/Task/CreateTaskTool.php index 922e8aa..7919819 100644 --- a/src/Mcp/Tool/Task/CreateTaskTool.php +++ b/src/Mcp/Tool/Task/CreateTaskTool.php @@ -24,7 +24,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException; use function sprintf; -#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs.')] +#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs. The status parameter must reference a status that belongs to the target project\'s workflow — otherwise the call is rejected with a validation error.')] class CreateTaskTool { public function __construct( diff --git a/src/Mcp/Tool/Task/UpdateTaskTool.php b/src/Mcp/Tool/Task/UpdateTaskTool.php index f078cd6..82c0c6b 100644 --- a/src/Mcp/Tool/Task/UpdateTaskTool.php +++ b/src/Mcp/Tool/Task/UpdateTaskTool.php @@ -22,7 +22,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException; use function sprintf; -#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs.')] +#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs. The status parameter must reference a status that belongs to the task\'s project workflow — otherwise the call is rejected with a validation error.')] class UpdateTaskTool { public function __construct( diff --git a/src/Mcp/Tool/TaskMeta/ListStatusesTool.php b/src/Mcp/Tool/TaskMeta/ListStatusesTool.php index b516933..db6c8e3 100644 --- a/src/Mcp/Tool/TaskMeta/ListStatusesTool.php +++ b/src/Mcp/Tool/TaskMeta/ListStatusesTool.php @@ -4,33 +4,49 @@ 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 all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')] +#[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(): string + public function __invoke(?int $projectId = null): string { if (!$this->security->isGranted('ROLE_USER')) { throw new AccessDeniedException('Access denied: ROLE_USER required.'); } - $statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']); + if (null !== $projectId) { + $project = $this->entityManager->find(Project::class, $projectId); + if (!$project) { + return json_encode(['error' => 'Project not found.']); + } + $statuses = $project->getWorkflow()->getStatuses()->toArray(); + } 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(), + 'id' => $s->getId(), + 'label' => $s->getLabel(), + 'color' => $s->getColor(), + 'position' => $s->getPosition(), + 'isFinal' => $s->getIsFinal(), + 'category' => $s->getCategory()->value, + 'workflowId' => $s->getWorkflow()?->getId(), ], $statuses)); } } diff --git a/src/Mcp/Tool/Workflow/ListWorkflowsTool.php b/src/Mcp/Tool/Workflow/ListWorkflowsTool.php new file mode 100644 index 0000000..03701d2 --- /dev/null +++ b/src/Mcp/Tool/Workflow/ListWorkflowsTool.php @@ -0,0 +1,46 @@ +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)); + } +} diff --git a/src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php b/src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php new file mode 100644 index 0000000..f0448f7 --- /dev/null +++ b/src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php @@ -0,0 +1,65 @@ + $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.']); + } + + $fakeRequest = Request::create('', 'POST', [], [], [], [], json_encode([ + 'workflowId' => $workflowId, + 'mapping' => $mapping, + ])); + + try { + $result = $this->processor->process( + $project, + operation: new 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, + ]); + } +} diff --git a/src/Repository/WorkflowRepository.php b/src/Repository/WorkflowRepository.php new file mode 100644 index 0000000..fafd288 --- /dev/null +++ b/src/Repository/WorkflowRepository.php @@ -0,0 +1,25 @@ + + */ +class WorkflowRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Workflow::class); + } + + public function findDefault(): ?Workflow + { + return $this->findOneBy(['isDefault' => true]); + } +} diff --git a/src/State/SwitchProjectWorkflowProcessor.php b/src/State/SwitchProjectWorkflowProcessor.php new file mode 100644 index 0000000..c5822ae --- /dev/null +++ b/src/State/SwitchProjectWorkflowProcessor.php @@ -0,0 +1,113 @@ + + */ +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, + ); + } +} diff --git a/src/State/WorkflowDeleteProcessor.php b/src/State/WorkflowDeleteProcessor.php new file mode 100644 index 0000000..a0efec9 --- /dev/null +++ b/src/State/WorkflowDeleteProcessor.php @@ -0,0 +1,42 @@ + + */ +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(); + } +}