Files
Lesstime/docs/plans/2026-03-09-task-management.md
matthieu 8c56ee6dd7 chore : update project documentation and config
Update CLAUDE.md structure, add implementation plans, fix
config/reference.php and MeProvider comment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:40:49 +01:00

67 KiB

Task Management (Tickets + Kanban) Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Ajouter un système de gestion de tâches (tickets) avec vue Kanban et Backlog par projet, incluant des entités configurables (Status, Effort, Priorité, Type, Groupe) administrables via un onglet admin.

Architecture: 6 nouvelles entités Doctrine exposées via API Platform. Le frontend ajoute une page admin avec CRUD pour les entités de configuration, et une page projet avec Kanban + Backlog. Les patterns existants (Entity → Repository → ApiResource, Service → DTO → Drawer) sont réutilisés à l'identique.

Tech Stack: Symfony 8 / API Platform 4 / Doctrine ORM (backend), Nuxt 4 / Vue 3 / Pinia / Tailwind CSS (frontend)


Modèle de données

TaskStatus    { id, label, color, position }           — ex: "A faire", "En cours", "Bloqué"…
TaskEffort    { id, label }                             — ex: "S", "M", "L", "XL", "XXL"
TaskPriority  { id, label, color }                      — ex: "Basse" (bleu), "Moyen" (orange), "Haute" (rouge)
TaskType      { id, label, color }                      — ex: "Gestion mdp", "Connexion", "Calendrier"
TaskGroup     { id, title, description, color, project } — lié à un projet, ex: groupe filtrable
Task          { id, title, description, status, effort, priority, assignee(User), group, project, types(M2M) }

Phase 1 : Backend — Entités de configuration

Task 1.1 : Entité TaskStatus

Files:

  • Create: src/Entity/TaskStatus.php
  • Create: src/Repository/TaskStatusRepository.php

Step 1: Create TaskStatusRepository

<?php

declare(strict_types=1);

namespace App\Repository;

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

/**
 * @extends ServiceEntityRepository<TaskStatus>
 */
class TaskStatusRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, TaskStatus::class);
    }
}

Step 2: Create TaskStatus entity

<?php

declare(strict_types=1);

namespace App\Entity;

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

#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['task_status:read']],
    denormalizationContext: ['groups' => ['task_status:write']],
    order: ['position' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: TaskStatusRepository::class)]
class TaskStatus
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['task_status:read', 'task:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['task_status:read', 'task_status:write', 'task:read'])]
    private ?string $label = null;

    #[ORM\Column(length: 7)]
    #[Groups(['task_status:read', 'task_status:write', 'task:read'])]
    private ?string $color = '#222783';

    #[ORM\Column(type: 'integer')]
    #[Groups(['task_status:read', 'task_status:write', 'task:read'])]
    private int $position = 0;

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

Step 3: Commit

git add src/Entity/TaskStatus.php src/Repository/TaskStatusRepository.php
git commit -m "feat : add TaskStatus entity"

Task 1.2 : Entité TaskEffort

Files:

  • Create: src/Entity/TaskEffort.php
  • Create: src/Repository/TaskEffortRepository.php

Step 1: Create TaskEffortRepository (même pattern que TaskStatusRepository)

Step 2: Create TaskEffort entity

<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TaskEffortRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['task_effort:read']],
    denormalizationContext: ['groups' => ['task_effort:write']],
    order: ['label' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: TaskEffortRepository::class)]
class TaskEffort
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['task_effort:read', 'task:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 50)]
    #[Groups(['task_effort:read', 'task_effort:write', 'task:read'])]
    private ?string $label = 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;
    }
}

Step 3: Commit

git add src/Entity/TaskEffort.php src/Repository/TaskEffortRepository.php
git commit -m "feat : add TaskEffort entity"

Task 1.3 : Entité TaskPriority

Files:

  • Create: src/Entity/TaskPriority.php
  • Create: src/Repository/TaskPriorityRepository.php

Step 1: Create TaskPriorityRepository (même pattern)

Step 2: Create TaskPriority entity

<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TaskPriorityRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['task_priority:read']],
    denormalizationContext: ['groups' => ['task_priority:write']],
    order: ['label' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: TaskPriorityRepository::class)]
class TaskPriority
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['task_priority:read', 'task:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['task_priority:read', 'task_priority:write', 'task:read'])]
    private ?string $label = null;

    #[ORM\Column(length: 7)]
    #[Groups(['task_priority:read', 'task_priority:write', 'task:read'])]
    private ?string $color = '#222783';

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

Step 3: Commit

git add src/Entity/TaskPriority.php src/Repository/TaskPriorityRepository.php
git commit -m "feat : add TaskPriority entity"

Task 1.4 : Entité TaskType

Files:

  • Create: src/Entity/TaskType.php
  • Create: src/Repository/TaskTypeRepository.php

Step 1: Create TaskTypeRepository (même pattern)

Step 2: Create TaskType entity

Structure identique à TaskPriority : id, label (255), color (7, default #222783).

<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TaskTypeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['task_type:read']],
    denormalizationContext: ['groups' => ['task_type:write']],
    order: ['label' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: TaskTypeRepository::class)]
class TaskType
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['task_type:read', 'task:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['task_type:read', 'task_type:write', 'task:read'])]
    private ?string $label = null;

    #[ORM\Column(length: 7)]
    #[Groups(['task_type:read', 'task_type:write', 'task:read'])]
    private ?string $color = '#222783';

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

Step 3: Commit

git add src/Entity/TaskType.php src/Repository/TaskTypeRepository.php
git commit -m "feat : add TaskType entity"

Task 1.5 : Entité TaskGroup

Files:

  • Create: src/Entity/TaskGroup.php
  • Create: src/Repository/TaskGroupRepository.php

Step 1: Create TaskGroupRepository (même pattern)

Step 2: Create TaskGroup entity

<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TaskGroupRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['task_group:read']],
    denormalizationContext: ['groups' => ['task_group:write']],
    order: ['title' => 'ASC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact'])]
#[ORM\Entity(repositoryClass: TaskGroupRepository::class)]
class TaskGroup
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['task_group:read', 'task:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['task_group:read', 'task_group:write', 'task:read'])]
    private ?string $title = null;

    #[ORM\Column(type: 'text', nullable: true)]
    #[Groups(['task_group:read', 'task_group:write'])]
    private ?string $description = null;

    #[ORM\Column(length: 7)]
    #[Groups(['task_group:read', 'task_group:write', 'task:read'])]
    private ?string $color = '#222783';

    #[ORM\ManyToOne(targetEntity: Project::class)]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    #[Groups(['task_group:read', 'task_group:write'])]
    private ?Project $project = null;

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

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(?string $description): static
    {
        $this->description = $description;

        return $this;
    }

    public function getColor(): ?string
    {
        return $this->color;
    }

    public function setColor(string $color): static
    {
        $this->color = $color;

        return $this;
    }

    public function getProject(): ?Project
    {
        return $this->project;
    }

    public function setProject(?Project $project): static
    {
        $this->project = $project;

        return $this;
    }
}

Step 3: Commit

git add src/Entity/TaskGroup.php src/Repository/TaskGroupRepository.php
git commit -m "feat : add TaskGroup entity"

Task 1.6 : Entité Task

Files:

  • Create: src/Entity/Task.php
  • Create: src/Repository/TaskRepository.php

Step 1: Create TaskRepository (même pattern)

Step 2: Create Task entity

<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TaskRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['task:read']],
    denormalizationContext: ['groups' => ['task:write']],
    order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact'])]
#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['task:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['task:read', 'task:write'])]
    private ?string $title = null;

    #[ORM\Column(type: 'text', nullable: true)]
    #[Groups(['task:read', 'task:write'])]
    private ?string $description = null;

    #[ORM\ManyToOne(targetEntity: TaskStatus::class)]
    #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
    #[Groups(['task:read', 'task:write'])]
    private ?TaskStatus $status = null;

    #[ORM\ManyToOne(targetEntity: TaskEffort::class)]
    #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
    #[Groups(['task:read', 'task:write'])]
    private ?TaskEffort $effort = null;

    #[ORM\ManyToOne(targetEntity: TaskPriority::class)]
    #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
    #[Groups(['task:read', 'task:write'])]
    private ?TaskPriority $priority = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
    #[Groups(['task:read', 'task:write'])]
    private ?User $assignee = null;

    #[ORM\ManyToOne(targetEntity: TaskGroup::class)]
    #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
    #[Groups(['task:read', 'task:write'])]
    private ?TaskGroup $group = null;

    #[ORM\ManyToOne(targetEntity: Project::class)]
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    #[Groups(['task:read', 'task:write'])]
    private ?Project $project = null;

    /** @var Collection<int, TaskType> */
    #[ORM\ManyToMany(targetEntity: TaskType::class)]
    #[ORM\JoinTable(name: 'task_task_type')]
    #[Groups(['task:read', 'task:write'])]
    private Collection $types;

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

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

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(?string $description): static
    {
        $this->description = $description;

        return $this;
    }

    public function getStatus(): ?TaskStatus
    {
        return $this->status;
    }

    public function setStatus(?TaskStatus $status): static
    {
        $this->status = $status;

        return $this;
    }

    public function getEffort(): ?TaskEffort
    {
        return $this->effort;
    }

    public function setEffort(?TaskEffort $effort): static
    {
        $this->effort = $effort;

        return $this;
    }

    public function getPriority(): ?TaskPriority
    {
        return $this->priority;
    }

    public function setPriority(?TaskPriority $priority): static
    {
        $this->priority = $priority;

        return $this;
    }

    public function getAssignee(): ?User
    {
        return $this->assignee;
    }

    public function setAssignee(?User $assignee): static
    {
        $this->assignee = $assignee;

        return $this;
    }

    public function getGroup(): ?TaskGroup
    {
        return $this->group;
    }

    public function setGroup(?TaskGroup $group): static
    {
        $this->group = $group;

        return $this;
    }

    public function getProject(): ?Project
    {
        return $this->project;
    }

    public function setProject(?Project $project): static
    {
        $this->project = $project;

        return $this;
    }

    /** @return Collection<int, TaskType> */
    public function getTypes(): Collection
    {
        return $this->types;
    }

    public function addType(TaskType $type): static
    {
        if (!$this->types->contains($type)) {
            $this->types->add($type);
        }

        return $this;
    }

    public function removeType(TaskType $type): static
    {
        $this->types->removeElement($type);

        return $this;
    }
}

Note : ajouter aussi 'task:read' aux groups de User.id et User.username dans src/Entity/User.php pour que l'assignee soit sérialisé correctement :

// Dans User.php, ajouter 'task:read' aux groups de id et username
#[Groups(['me:read', 'task:read'])]
private ?int $id = null;

#[Groups(['me:read', 'task:read'])]
private ?string $username = null;

Step 3: Commit

git add src/Entity/Task.php src/Repository/TaskRepository.php src/Entity/User.php
git commit -m "feat : add Task entity with relations"

Task 1.7 : Migration + Fixtures

Step 1: Générer et exécuter la migration

make shell
# Dans le container :
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
exit

Step 2: Mettre à jour les fixtures dans src/DataFixtures/AppFixtures.php

Ajouter après les projets existants :

// --- TaskStatus (ordonnés par position) ---
$statusTodo = new TaskStatus();
$statusTodo->setLabel('A faire');
$statusTodo->setColor('#1565C0');
$statusTodo->setPosition(0);
$manager->persist($statusTodo);

$statusInProgress = new TaskStatus();
$statusInProgress->setLabel('En cours');
$statusInProgress->setColor('#6A6A6A');
$statusInProgress->setPosition(1);
$manager->persist($statusInProgress);

$statusBlocked = new TaskStatus();
$statusBlocked->setLabel('Bloqué');
$statusBlocked->setColor('#1565C0');
$statusBlocked->setPosition(2);
$manager->persist($statusBlocked);

$statusReview = new TaskStatus();
$statusReview->setLabel('En attente de validation');
$statusReview->setColor('#1565C0');
$statusReview->setPosition(3);
$manager->persist($statusReview);

$statusDone = new TaskStatus();
$statusDone->setLabel('Terminé');
$statusDone->setColor('#1565C0');
$statusDone->setPosition(4);
$manager->persist($statusDone);

// --- TaskEffort ---
$effortS = new TaskEffort();
$effortS->setLabel('S');
$manager->persist($effortS);

$effortM = new TaskEffort();
$effortM->setLabel('M');
$manager->persist($effortM);

$effortL = new TaskEffort();
$effortL->setLabel('L');
$manager->persist($effortL);

$effortXL = new TaskEffort();
$effortXL->setLabel('XL');
$manager->persist($effortXL);

$effortXXL = new TaskEffort();
$effortXXL->setLabel('XXL');
$manager->persist($effortXXL);

// --- TaskPriority ---
$priorityLow = new TaskPriority();
$priorityLow->setLabel('Basse');
$priorityLow->setColor('#1565C0');
$manager->persist($priorityLow);

$priorityMedium = new TaskPriority();
$priorityMedium->setLabel('Moyen');
$priorityMedium->setColor('#FF8F00');
$manager->persist($priorityMedium);

$priorityHigh = new TaskPriority();
$priorityHigh->setLabel('Haute');
$priorityHigh->setColor('#C62828');
$manager->persist($priorityHigh);

// --- TaskType ---
$typePassword = new TaskType();
$typePassword->setLabel('Gestion mdp');
$typePassword->setColor('#C62828');
$manager->persist($typePassword);

$typeAuth = new TaskType();
$typeAuth->setLabel('Connexion');
$typeAuth->setColor('#FF8F00');
$manager->persist($typeAuth);

$typeCalendar = new TaskType();
$typeCalendar->setLabel('Calendrier');
$typeCalendar->setColor('#1565C0');
$manager->persist($typeCalendar);

// --- TaskGroup (liés au projet SIRH) ---
$groupFrontend = new TaskGroup();
$groupFrontend->setTitle('Frontend');
$groupFrontend->setColor('#4A90D9');
$groupFrontend->setProject($projectSirh);
$manager->persist($groupFrontend);

$groupBackend = new TaskGroup();
$groupBackend->setTitle('Backend');
$groupBackend->setColor('#26A69A');
$groupBackend->setProject($projectSirh);
$manager->persist($groupBackend);

// --- Tasks (projet SIRH) ---
$task1 = new Task();
$task1->setTitle('Création d\'une page de login');
$task1->setDescription('Implémenter la page de connexion avec formulaire.');
$task1->setStatus($statusTodo);
$task1->setEffort($effortXXL);
$task1->setPriority($priorityLow);
$task1->setAssignee($admin);
$task1->setGroup($groupFrontend);
$task1->setProject($projectSirh);
$task1->addType($typePassword);
$manager->persist($task1);

$task2 = new Task();
$task2->setTitle('Création d\'une page de login');
$task2->setDescription('Gérer la connexion OAuth.');
$task2->setStatus($statusTodo);
$task2->setEffort($effortL);
$task2->setPriority($priorityHigh);
$task2->setAssignee($admin);
$task2->setGroup($groupFrontend);
$task2->setProject($projectSirh);
$task2->addType($typeAuth);
$manager->persist($task2);

$task3 = new Task();
$task3->setTitle('Création d\'une page de login');
$task3->setStatus($statusInProgress);
$task3->setEffort($effortXXL);
$task3->setPriority($priorityLow);
$task3->setAssignee($admin);
$task3->setGroup($groupBackend);
$task3->setProject($projectSirh);
$task3->addType($typePassword);
$manager->persist($task3);

$task4 = new Task();
$task4->setTitle('Création d\'une page de login');
$task4->setStatus($statusBlocked);
$task4->setEffort($effortXXL);
$task4->setPriority($priorityLow);
$task4->setAssignee($admin);
$task4->setProject($projectSirh);
$task4->addType($typePassword);
$manager->persist($task4);

$task5 = new Task();
$task5->setTitle('Création d\'une page de login');
$task5->setStatus($statusReview);
$task5->setEffort($effortXXL);
$task5->setPriority($priorityMedium);
$task5->setAssignee($admin);
$task5->setProject($projectSirh);
$task5->addType($typeCalendar);
$manager->persist($task5);

$task6 = new Task();
$task6->setTitle('Création d\'une page de login');
$task6->setStatus($statusDone);
$task6->setEffort($effortXXL);
$task6->setPriority($priorityHigh);
$task6->setAssignee($admin);
$task6->setProject($projectSirh);
$task6->addType($typeAuth);
$manager->persist($task6);

Step 3: Recharger les fixtures

make db-reset

Step 4: Commit

git add src/DataFixtures/AppFixtures.php migrations/
git commit -m "feat : add task fixtures and migration"

Phase 2 : Frontend — Services & DTOs pour les entités de configuration

Task 2.1 : DTOs TypeScript

Files:

  • Create: frontend/services/dto/task-status.ts
  • Create: frontend/services/dto/task-effort.ts
  • Create: frontend/services/dto/task-priority.ts
  • Create: frontend/services/dto/task-type.ts
  • Create: frontend/services/dto/task-group.ts
  • Create: frontend/services/dto/task.ts

Step 1: Créer tous les DTOs

// frontend/services/dto/task-status.ts
export type TaskStatus = {
    id: number
    '@id'?: string
    label: string
    color: string
    position: number
}

export type TaskStatusWrite = {
    label: string
    color: string
    position: number
}
// frontend/services/dto/task-effort.ts
export type TaskEffort = {
    id: number
    '@id'?: string
    label: string
}

export type TaskEffortWrite = {
    label: string
}
// frontend/services/dto/task-priority.ts
export type TaskPriority = {
    id: number
    '@id'?: string
    label: string
    color: string
}

export type TaskPriorityWrite = {
    label: string
    color: string
}
// frontend/services/dto/task-type.ts
export type TaskType = {
    id: number
    '@id'?: string
    label: string
    color: string
}

export type TaskTypeWrite = {
    label: string
    color: string
}
// frontend/services/dto/task-group.ts
import type { Project } from './project'

export type TaskGroup = {
    id: number
    '@id'?: string
    title: string
    description: string | null
    color: string
    project: Project | null
}

export type TaskGroupWrite = {
    title: string
    description: string | null
    color: string
    project: string // IRI
}
// frontend/services/dto/task.ts
import type { TaskStatus } from './task-status'
import type { TaskEffort } from './task-effort'
import type { TaskPriority } from './task-priority'
import type { TaskType } from './task-type'
import type { TaskGroup } from './task-group'
import type { UserData } from './user-data'

export type Task = {
    id: number
    '@id'?: string
    title: string
    description: string | null
    status: TaskStatus | null
    effort: TaskEffort | null
    priority: TaskPriority | null
    assignee: UserData | null
    group: TaskGroup | null
    types: TaskType[]
}

export type TaskWrite = {
    title: string
    description: string | null
    status: string | null       // IRI
    effort: string | null       // IRI
    priority: string | null     // IRI
    assignee: string | null     // IRI
    group: string | null        // IRI
    project: string             // IRI
    types: string[]             // IRIs
}

Step 2: Commit

git add frontend/services/dto/task-status.ts frontend/services/dto/task-effort.ts \
  frontend/services/dto/task-priority.ts frontend/services/dto/task-type.ts \
  frontend/services/dto/task-group.ts frontend/services/dto/task.ts
git commit -m "feat : add task-related DTOs"

Task 2.2 : Services API

Files:

  • Create: frontend/services/task-statuses.ts
  • Create: frontend/services/task-efforts.ts
  • Create: frontend/services/task-priorities.ts
  • Create: frontend/services/task-types.ts
  • Create: frontend/services/task-groups.ts
  • Create: frontend/services/tasks.ts

Chaque service suit le pattern de useClientService() :

// frontend/services/task-statuses.ts
import type { TaskStatus, TaskStatusWrite } from './dto/task-status'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'

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

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

    async function create(payload: TaskStatusWrite): Promise<TaskStatus> {
        return api.post<TaskStatus>('/task_statuses', payload as Record<string, unknown>, {
            toastSuccessKey: 'taskStatuses.created',
        })
    }

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

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

    return { getAll, create, update, remove }
}

Reproduire ce pattern pour les 5 autres services :

  • useTaskEffortService()/task_efforts → clé i18n taskEfforts
  • useTaskPriorityService()/task_priorities → clé i18n taskPriorities
  • useTaskTypeService()/task_types → clé i18n taskTypes
  • useTaskGroupService()/task_groups → clé i18n taskGroups
  • useTaskService()/tasks → clé i18n tasks (+ paramètre query project pour filtrer)

Pour tasks.ts ajouter un getByProject :

// frontend/services/tasks.ts
import type { Task, TaskWrite } from './dto/task'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'

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

    async function getByProject(projectId: number): Promise<Task[]> {
        const data = await api.get<HydraCollection<Task>>('/tasks', { project: `/api/projects/${projectId}` })
        return extractHydraMembers(data)
    }

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

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

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

    return { getByProject, create, update, remove }
}

Step 2: Commit

git add frontend/services/task-statuses.ts frontend/services/task-efforts.ts \
  frontend/services/task-priorities.ts frontend/services/task-types.ts \
  frontend/services/task-groups.ts frontend/services/tasks.ts
git commit -m "feat : add task-related API services"

Task 2.3 : Traductions i18n

File: Modify frontend/i18n/locales/fr.json

Ajouter les clés :

{
    "taskStatuses": {
        "created": "Statut créé avec succès.",
        "updated": "Statut mis à jour avec succès.",
        "deleted": "Statut supprimé avec succès."
    },
    "taskEfforts": {
        "created": "Effort créé avec succès.",
        "updated": "Effort mis à jour avec succès.",
        "deleted": "Effort supprimé avec succès."
    },
    "taskPriorities": {
        "created": "Priorité créée avec succès.",
        "updated": "Priorité mise à jour avec succès.",
        "deleted": "Priorité supprimée avec succès."
    },
    "taskTypes": {
        "created": "Type créé avec succès.",
        "updated": "Type mis à jour avec succès.",
        "deleted": "Type supprimé avec succès."
    },
    "taskGroups": {
        "created": "Groupe créé avec succès.",
        "updated": "Groupe mis à jour avec succès.",
        "deleted": "Groupe supprimé avec succès."
    },
    "tasks": {
        "created": "Ticket créé avec succès.",
        "updated": "Ticket mis à jour avec succès.",
        "deleted": "Ticket supprimé avec succès."
    }
}

Step 2: Commit

git add frontend/i18n/locales/fr.json
git commit -m "feat : add i18n translations for task entities"

Phase 3 : Frontend — Page Admin (CRUD Status, Effort, Priorité, Type)

Task 3.1 : Page Admin avec onglets

Files:

  • Create: frontend/pages/admin.vue

La page affiche 4 onglets : Statuts, Efforts, Priorités, Types. Chaque onglet montre une liste avec bouton d'ajout et un drawer pour créer/modifier.

Step 1: Créer la page admin

<template>
    <div>
        <h1 class="text-2xl font-bold text-neutral-900">Administration</h1>

        <div class="mt-6 flex gap-2 border-b border-neutral-200">
            <button
                v-for="tab in tabs"
                :key="tab.key"
                class="px-4 py-2 text-sm font-semibold transition-colors"
                :class="activeTab === tab.key
                    ? 'border-b-2 border-primary-500 text-primary-500'
                    : 'text-neutral-500 hover:text-neutral-700'"
                @click="activeTab = tab.key"
            >
                {{ tab.label }}
            </button>
        </div>

        <div class="mt-6">
            <AdminStatusTab v-if="activeTab === 'statuses'" />
            <AdminEffortTab v-if="activeTab === 'efforts'" />
            <AdminPriorityTab v-if="activeTab === 'priorities'" />
            <AdminTypeTab v-if="activeTab === 'types'" />
        </div>
    </div>
</template>

<script setup lang="ts">
useHead({ title: 'Administration' })

const tabs = [
    { key: 'statuses', label: 'Statuts' },
    { key: 'efforts', label: 'Efforts' },
    { key: 'priorities', label: 'Priorités' },
    { key: 'types', label: 'Types' },
]

const activeTab = ref('statuses')
</script>

Step 2: Commit

git add frontend/pages/admin.vue
git commit -m "feat : add admin page with tabs"

Task 3.2 : Composant AdminStatusTab

Files:

  • Create: frontend/components/AdminStatusTab.vue
  • Create: frontend/components/TaskStatusDrawer.vue

Step 1: Créer TaskStatusDrawer

<template>
    <AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un statut' : 'Ajouter un statut'">
        <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
            <MalioInputText
                v-model="form.label"
                label="Libellé"
                input-class="w-full"
                :error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
                @blur="touched.label = true"
            />
            <MalioInputText
                v-model="form.position"
                label="Position"
                input-class="w-full"
                type="number"
            />
            <div class="mt-4">
                <ColorPicker v-model="form.color" />
            </div>

            <div class="mt-6 flex justify-end">
                <button
                    type="submit"
                    class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
                    :disabled="isSubmitting"
                >
                    Enregistrer
                </button>
            </div>
        </form>
    </AppDrawer>
</template>

<script setup lang="ts">
import type { TaskStatus, TaskStatusWrite } from '~/services/dto/task-status'
import { useTaskStatusService } from '~/services/task-statuses'

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

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

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

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

const form = reactive({
    label: '',
    color: '#1565C0',
    position: 0,
})

const touched = reactive({ label: false })

watch(() => props.modelValue, (open) => {
    if (open) {
        if (props.item) {
            form.label = props.item.label
            form.color = props.item.color
            form.position = props.item.position
        } else {
            form.label = ''
            form.color = '#1565C0'
            form.position = 0
        }
        touched.label = false
    }
})

const { create, update } = useTaskStatusService()

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

    isSubmitting.value = true
    try {
        const payload: TaskStatusWrite = {
            label: form.label.trim(),
            color: form.color,
            position: Number(form.position),
        }

        if (isEditing.value && props.item) {
            await update(props.item.id, payload)
        } else {
            await create(payload)
        }

        emit('saved')
        isOpen.value = false
    } finally {
        isSubmitting.value = false
    }
}
</script>

Step 2: Créer AdminStatusTab

<template>
    <div>
        <div class="flex items-center justify-between">
            <h2 class="text-lg font-semibold text-neutral-900">Statuts</h2>
            <button
                class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
                @click="openCreate"
            >
                + Ajouter un statut
            </button>
        </div>

        <div class="mt-4 overflow-x-auto rounded-lg border border-neutral-200">
            <table class="w-full text-left text-sm">
                <thead class="border-b border-neutral-200 bg-neutral-50">
                    <tr>
                        <th class="px-4 py-3 font-semibold text-neutral-700">Libellé</th>
                        <th class="px-4 py-3 font-semibold text-neutral-700">Couleur</th>
                        <th class="px-4 py-3 font-semibold text-neutral-700">Position</th>
                        <th class="px-4 py-3 font-semibold text-neutral-700 w-20">Actions</th>
                    </tr>
                </thead>
                <tbody>
                    <tr
                        v-for="item in items"
                        :key="item.id"
                        class="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
                        @click="openEdit(item)"
                    >
                        <td class="px-4 py-3 font-semibold text-primary-500">{{ item.label }}</td>
                        <td class="px-4 py-3">
                            <span class="inline-block h-5 w-5 rounded-full" :style="{ backgroundColor: item.color }" />
                        </td>
                        <td class="px-4 py-3 text-neutral-700">{{ item.position }}</td>
                        <td class="px-4 py-3">
                            <button
                                class="text-red-500 hover:text-red-700"
                                @click.stop="handleDelete(item)"
                            >
                                <Icon name="mdi:delete-outline" size="20" />
                            </button>
                        </td>
                    </tr>
                    <tr v-if="items.length === 0 && !isLoading">
                        <td colspan="4" class="px-4 py-8 text-center text-neutral-400">
                            Aucun statut trouvé.
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>

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

<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
import { useTaskStatusService } from '~/services/task-statuses'

const { getAll, remove } = useTaskStatusService()
const items = ref<TaskStatus[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<TaskStatus | null>(null)

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

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

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

async function handleDelete(item: TaskStatus) {
    await remove(item.id)
    await loadItems()
}

async function onSaved() {
    await loadItems()
}

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

Step 3: Commit

git add frontend/components/AdminStatusTab.vue frontend/components/TaskStatusDrawer.vue
git commit -m "feat : add admin status tab with drawer CRUD"

Task 3.3 : Composant AdminEffortTab

Files:

  • Create: frontend/components/AdminEffortTab.vue
  • Create: frontend/components/TaskEffortDrawer.vue

Même pattern que AdminStatusTab mais sans couleur ni position. Le drawer a juste un champ label. La table affiche juste Libellé et Actions.

Step 1: Commit

git add frontend/components/AdminEffortTab.vue frontend/components/TaskEffortDrawer.vue
git commit -m "feat : add admin effort tab with drawer CRUD"

Task 3.4 : Composant AdminPriorityTab

Files:

  • Create: frontend/components/AdminPriorityTab.vue
  • Create: frontend/components/TaskPriorityDrawer.vue

Même pattern que AdminStatusTab mais avec label + color (ColorPicker). Pas de position.

Step 1: Commit

git add frontend/components/AdminPriorityTab.vue frontend/components/TaskPriorityDrawer.vue
git commit -m "feat : add admin priority tab with drawer CRUD"

Task 3.5 : Composant AdminTypeTab

Files:

  • Create: frontend/components/AdminTypeTab.vue
  • Create: frontend/components/TaskTypeDrawer.vue

Identique à AdminPriorityTab : label + color.

Step 1: Commit

git add frontend/components/AdminTypeTab.vue frontend/components/TaskTypeDrawer.vue
git commit -m "feat : add admin type tab with drawer CRUD"

Task 3.6 : Ajouter le lien Admin dans la sidebar

File: Modify frontend/layouts/default.vue

Ajouter un nouveau NuxtLink après "Clients" dans la sidebar :

<NuxtLink
    to="/admin"
    class="flex gap-3 px-4 py-3 text-md font-semibold text-black hover:bg-tertiary-500 hover:text-primary-500"
    active-class="bg-tertiary-500 text-primary-500"
>
    <Icon name="mdi:cog-outline" size="24"/>
    <span class="self-baseline text-md">Administration</span>
</NuxtLink>

Step 1: Commit

git add frontend/layouts/default.vue
git commit -m "feat : add Admin nav link in sidebar"

Phase 4 : Frontend — Page Projet (Kanban + Backlog)

Task 4.1 : Page projet dynamique

Files:

  • Create: frontend/pages/projects/[id].vue

Cette page affiche le board d'un projet. Elle charge : le projet, les tasks, les statuses, les groupes, les efforts, les priorités, les types, les users.

<template>
    <div v-if="project">
        <div class="flex items-center justify-between">
            <h1 class="text-2xl font-bold text-neutral-900">{{ project.name }}</h1>
            <div class="flex gap-3">
                <button
                    class="rounded-md bg-secondary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-600"
                    @click="openGroupCreate"
                >
                    + Ajouter un groupe
                </button>
                <button
                    class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
                    @click="openTaskCreate"
                >
                    + Ajouter un ticket
                </button>
            </div>
        </div>

        <!-- Filtre groupe -->
        <div class="mt-4">
            <MalioSelect
                v-model="selectedGroupId"
                :options="groupOptions"
                label=""
                empty-option-label="Tous les groupes"
                min-width="w-64"
            />
        </div>

        <!-- Kanban -->
        <div class="mt-6 flex gap-4 overflow-x-auto pb-4">
            <div
                v-for="status in statuses"
                :key="status.id"
                class="flex w-64 flex-shrink-0 flex-col rounded-lg border border-neutral-200"
            >
                <div
                    class="rounded-t-lg px-4 py-3 text-sm font-bold text-white"
                    :style="{ backgroundColor: status.color }"
                >
                    {{ status.label }}
                </div>
                <div class="flex flex-col gap-3 p-3">
                    <TaskCard
                        v-for="task in tasksByStatus(status.id)"
                        :key="task.id"
                        :task="task"
                        @click="openTaskEdit(task)"
                    />
                </div>
            </div>
        </div>

        <!-- Backlog -->
        <div class="mt-8">
            <h2 class="text-lg font-bold text-neutral-900">Backlog</h2>
            <div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
                <div
                    v-for="task in filteredTasks"
                    :key="task.id"
                    class="flex cursor-pointer items-center justify-between rounded-lg border border-neutral-200 bg-white px-4 py-3 hover:shadow-sm"
                    @click="openTaskEdit(task)"
                >
                    <span class="font-semibold text-neutral-900">{{ task.title }}</span>
                    <div class="flex items-center gap-2">
                        <span
                            v-for="type in task.types"
                            :key="type.id"
                            class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
                            :style="{ backgroundColor: type.color }"
                        >
                            {{ type.label }}
                        </span>
                        <span
                            v-if="task.priority"
                            class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
                            :style="{ backgroundColor: task.priority.color }"
                        >
                            {{ task.priority.label }}
                        </span>
                        <span v-if="task.effort" class="text-sm font-bold text-neutral-700">
                            {{ task.effort.label }}
                        </span>
                    </div>
                </div>
            </div>
        </div>

        <!-- Drawers -->
        <TaskDrawer
            v-model="taskDrawerOpen"
            :task="selectedTask"
            :project-id="project.id"
            :statuses="statuses"
            :efforts="efforts"
            :priorities="priorities"
            :types="types"
            :groups="groups"
            @saved="onSaved"
        />
        <TaskGroupDrawer
            v-model="groupDrawerOpen"
            :group="null"
            :project-id="project.id"
            @saved="onSaved"
        />
    </div>
</template>

<script setup lang="ts">
import type { Project } from '~/services/dto/project'
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskType } from '~/services/dto/task-type'
import type { TaskGroup } from '~/services/dto/task-group'
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 { useTaskTypeService } from '~/services/task-types'
import { useTaskGroupService } from '~/services/task-groups'

const route = useRoute()
const projectId = computed(() => Number(route.params.id))

const project = ref<Project | null>(null)
const tasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const efforts = ref<TaskEffort[]>([])
const priorities = ref<TaskPriority[]>([])
const types = ref<TaskType[]>([])
const groups = ref<TaskGroup[]>([])

const selectedGroupId = ref<number | null>(null)
const taskDrawerOpen = ref(false)
const groupDrawerOpen = ref(false)
const selectedTask = ref<Task | null>(null)

useHead({ title: computed(() => project.value?.name ?? 'Projet') })

const groupOptions = computed(() =>
    groups.value.map(g => ({ label: g.title, value: g.id }))
)

const filteredTasks = computed(() => {
    if (!selectedGroupId.value) return tasks.value
    return tasks.value.filter(t => t.group?.id === selectedGroupId.value)
})

function tasksByStatus(statusId: number): Task[] {
    return filteredTasks.value.filter(t => t.status?.id === statusId)
}

function openTaskCreate() {
    selectedTask.value = null
    taskDrawerOpen.value = true
}

function openTaskEdit(task: Task) {
    selectedTask.value = task
    taskDrawerOpen.value = true
}

function openGroupCreate() {
    groupDrawerOpen.value = true
}

async function loadData() {
    const api = useApi()
    const [p, t, s, e, pr, ty, g] = await Promise.all([
        useProjectService().getById(projectId.value),
        useTaskService().getByProject(projectId.value),
        useTaskStatusService().getAll(),
        useTaskEffortService().getAll(),
        useTaskPriorityService().getAll(),
        useTaskTypeService().getAll(),
        useTaskGroupService().getByProject(projectId.value),
    ])
    project.value = p
    tasks.value = t
    statuses.value = s
    efforts.value = e
    priorities.value = pr
    types.value = ty
    groups.value = g
}

async function onSaved() {
    await loadData()
}

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

Note : il faut ajouter getById dans useProjectService() :

async function getById(id: number): Promise<Project> {
    return api.get<Project>(`/projects/${id}`)
}

Et getByProject dans useTaskGroupService() :

async function getByProject(projectId: number): Promise<TaskGroup[]> {
    const data = await api.get<HydraCollection<TaskGroup>>('/task_groups', { project: `/api/projects/${projectId}` })
    return extractHydraMembers(data)
}

Step 2: Commit

git add frontend/pages/projects/\[id\].vue frontend/services/projects.ts frontend/services/task-groups.ts
git commit -m "feat : add project board page with kanban and backlog"

Task 4.2 : Composant TaskCard (carte Kanban)

Files:

  • Create: frontend/components/TaskCard.vue
<template>
    <div
        class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm hover:shadow-md transition"
        @click="$emit('click')"
    >
        <div class="flex items-start justify-between">
            <span class="text-sm font-semibold text-neutral-900">{{ task.title }}</span>
            <button class="text-neutral-400 hover:text-neutral-600">
                <Icon name="mdi:eye-outline" size="18" />
            </button>
        </div>
        <div class="mt-2 flex flex-wrap items-center gap-1.5">
            <span
                v-if="task.priority"
                class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
                :style="{ backgroundColor: task.priority.color }"
            >
                {{ task.priority.label }}
            </span>
            <span
                v-for="type in task.types"
                :key="type.id"
                class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
                :style="{ backgroundColor: type.color }"
            >
                {{ type.label }}
            </span>
            <span v-if="task.effort" class="ml-auto text-xs font-bold text-neutral-600">
                {{ task.effort.label }}
            </span>
            <span
                v-if="task.assignee"
                class="flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-xs font-bold text-white"
            >
                {{ task.assignee.username.charAt(0).toUpperCase() }}
            </span>
        </div>
    </div>
</template>

<script setup lang="ts">
import type { Task } from '~/services/dto/task'

defineProps<{
    task: Task
}>()

defineEmits<{
    (e: 'click'): void
}>()
</script>

Step 1: Commit

git add frontend/components/TaskCard.vue
git commit -m "feat : add TaskCard component for kanban"

Task 4.3 : Composant TaskDrawer (créer/modifier un ticket)

Files:

  • Create: frontend/components/TaskDrawer.vue
<template>
    <AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un ticket' : 'Ajouter un ticket'">
        <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
            <MalioInputText
                v-model="form.title"
                label="Titre"
                input-class="w-full"
                :error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
                @blur="touched.title = true"
            />
            <MalioInputTextArea
                v-model="form.description"
                label="Description"
                :size="3"
            />
            <MalioSelect
                v-model="form.statusId"
                :options="statusOptions"
                label="Status"
                empty-option-label="Aucun"
                min-width="w-full"
            />
            <MalioSelect
                v-model="form.effortId"
                :options="effortOptions"
                label="Effort"
                empty-option-label="Aucun"
                min-width="w-full"
            />
            <MalioSelect
                v-model="form.priorityId"
                :options="priorityOptions"
                label="Priorité"
                empty-option-label="Aucune"
                min-width="w-full"
            />
            <MalioSelect
                v-model="form.assigneeId"
                :options="userOptions"
                label="User"
                empty-option-label="Aucun"
                min-width="w-full"
            />
            <!-- Types : multi-select via checkboxes -->
            <div>
                <p class="mb-1 text-sm font-medium text-neutral-700">Type</p>
                <div class="flex flex-wrap gap-2">
                    <label
                        v-for="type in types"
                        :key="type.id"
                        class="flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold cursor-pointer transition"
                        :class="form.typeIds.includes(type.id)
                            ? 'text-white border-transparent'
                            : 'text-neutral-700 border-neutral-300 bg-white'"
                        :style="form.typeIds.includes(type.id) ? { backgroundColor: type.color } : {}"
                    >
                        <input
                            type="checkbox"
                            :value="type.id"
                            v-model="form.typeIds"
                            class="hidden"
                        />
                        {{ type.label }}
                    </label>
                </div>
            </div>
            <MalioSelect
                v-model="form.groupId"
                :options="groupOptions"
                label="Groupe"
                empty-option-label="Aucun"
                min-width="w-full"
            />

            <div class="mt-6 flex justify-end">
                <button
                    type="submit"
                    class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
                    :disabled="isSubmitting"
                >
                    Enregistrer
                </button>
            </div>
        </form>
    </AppDrawer>
</template>

<script setup lang="ts">
import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskType } from '~/services/dto/task-type'
import type { TaskGroup } from '~/services/dto/task-group'
import { useTaskService } from '~/services/tasks'

const props = defineProps<{
    modelValue: boolean
    task: Task | null
    projectId: number
    statuses: TaskStatus[]
    efforts: TaskEffort[]
    priorities: TaskPriority[]
    types: TaskType[]
    groups: TaskGroup[]
}>()

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

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

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

const form = reactive({
    title: '',
    description: '',
    statusId: null as number | null,
    effortId: null as number | null,
    priorityId: null as number | null,
    assigneeId: null as number | null,
    groupId: null as number | null,
    typeIds: [] as number[],
})

const touched = reactive({ title: false })

// Note : pour la liste des users, on pourrait ajouter un endpoint /users
// Pour l'instant on n'a que l'admin, on laissera le select vide ou on ajoutera plus tard
const userOptions = computed(() => [] as { label: string; value: number }[])

const statusOptions = computed(() => props.statuses.map(s => ({ label: s.label, value: s.id })))
const effortOptions = computed(() => props.efforts.map(e => ({ label: e.label, value: e.id })))
const priorityOptions = computed(() => props.priorities.map(p => ({ label: p.label, value: p.id })))
const groupOptions = computed(() => props.groups.map(g => ({ label: g.title, value: g.id })))

watch(() => props.modelValue, (open) => {
    if (open) {
        if (props.task) {
            form.title = props.task.title ?? ''
            form.description = props.task.description ?? ''
            form.statusId = props.task.status?.id ?? null
            form.effortId = props.task.effort?.id ?? null
            form.priorityId = props.task.priority?.id ?? null
            form.assigneeId = props.task.assignee?.id ?? null
            form.groupId = props.task.group?.id ?? null
            form.typeIds = props.task.types.map(t => t.id)
        } else {
            form.title = ''
            form.description = ''
            form.statusId = null
            form.effortId = null
            form.priorityId = null
            form.assigneeId = null
            form.groupId = null
            form.typeIds = []
        }
        touched.title = false
    }
})

const { create, update } = useTaskService()

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

    isSubmitting.value = true
    try {
        const payload: TaskWrite = {
            title: form.title.trim(),
            description: form.description.trim() || null,
            status: form.statusId ? `/api/task_statuses/${form.statusId}` : null,
            effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
            priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
            assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
            group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
            project: `/api/projects/${props.projectId}`,
            types: form.typeIds.map(id => `/api/task_types/${id}`),
        }

        if (isEditing.value && props.task) {
            await update(props.task.id, payload)
        } else {
            await create(payload)
        }

        emit('saved')
        isOpen.value = false
    } finally {
        isSubmitting.value = false
    }
}
</script>

Step 1: Commit

git add frontend/components/TaskDrawer.vue
git commit -m "feat : add TaskDrawer component for ticket create/edit"

Task 4.4 : Composant TaskGroupDrawer

Files:

  • Create: frontend/components/TaskGroupDrawer.vue
<template>
    <AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'">
        <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
            <MalioInputText
                v-model="form.title"
                label="Titre"
                input-class="w-full"
                :error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
                @blur="touched.title = true"
            />
            <MalioInputTextArea
                v-model="form.description"
                label="Description"
                :size="3"
            />
            <div class="mt-4">
                <ColorPicker v-model="form.color" />
            </div>

            <div class="mt-6 flex justify-end">
                <button
                    type="submit"
                    class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
                    :disabled="isSubmitting"
                >
                    Enregistrer
                </button>
            </div>
        </form>
    </AppDrawer>
</template>

<script setup lang="ts">
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
import { useTaskGroupService } from '~/services/task-groups'

const props = defineProps<{
    modelValue: boolean
    group: TaskGroup | null
    projectId: number
}>()

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

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

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

const form = reactive({
    title: '',
    description: '',
    color: '#222783',
})

const touched = reactive({ title: false })

watch(() => props.modelValue, (open) => {
    if (open) {
        if (props.group) {
            form.title = props.group.title
            form.description = props.group.description ?? ''
            form.color = props.group.color
        } else {
            form.title = ''
            form.description = ''
            form.color = '#222783'
        }
        touched.title = false
    }
})

const { create, update } = useTaskGroupService()

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

    isSubmitting.value = true
    try {
        const payload: TaskGroupWrite = {
            title: form.title.trim(),
            description: form.description.trim() || null,
            color: form.color,
            project: `/api/projects/${props.projectId}`,
        }

        if (isEditing.value && props.group) {
            await update(props.group.id, payload)
        } else {
            await create(payload)
        }

        emit('saved')
        isOpen.value = false
    } finally {
        isSubmitting.value = false
    }
}
</script>

Step 1: Commit

git add frontend/components/TaskGroupDrawer.vue
git commit -m "feat : add TaskGroupDrawer component"

Task 4.5 : Navigation projet → board

File: Modify frontend/pages/projects.vue

Changer le clic sur une carte projet pour naviguer vers le board au lieu d'ouvrir le drawer d'édition. Ajouter un bouton d'édition séparé sur la carte.

Remplacer le @click="openEdit(project)" sur la carte par @click="navigateTo(/projects/${project.id})".

Garder le bouton edit avec un @click.stop="openEdit(project)" sur un petit bouton icon dans la carte.

Step 1: Commit

git add frontend/pages/projects.vue
git commit -m "feat : navigate to project board on card click"

Phase 5 : Vérification finale

Task 5.1 : Vérification

Step 1: Lancer make db-reset pour vérifier les fixtures

Step 2: Lancer make dev-nuxt et vérifier :

  • Page admin accessible, CRUD status/effort/priorité/type fonctionnels
  • Clic sur un projet → board avec kanban + backlog
  • Filtre par groupe fonctionne
  • Création de ticket via drawer
  • Création de groupe via drawer

Step 3: Lancer make php-cs-fixer-allow-risky pour fixer le code style PHP

Step 4: Commit final

git add -A
git commit -m "fix : code style and final adjustments"

Résumé des fichiers

Backend (à créer)

Fichier Description
src/Entity/TaskStatus.php Entité statut (label, color, position)
src/Entity/TaskEffort.php Entité effort (label)
src/Entity/TaskPriority.php Entité priorité (label, color)
src/Entity/TaskType.php Entité type (label, color)
src/Entity/TaskGroup.php Entité groupe (title, description, color, project)
src/Entity/Task.php Entité tâche (title, desc, relations)
src/Repository/TaskStatus…Repository.php 6 repositories

Backend (à modifier)

Fichier Modification
src/Entity/User.php Ajouter group task:read sur id et username
src/DataFixtures/AppFixtures.php Ajouter fixtures pour toutes les nouvelles entités

Frontend (à créer)

Fichier Description
frontend/services/dto/task-status.ts DTO TaskStatus
frontend/services/dto/task-effort.ts DTO TaskEffort
frontend/services/dto/task-priority.ts DTO TaskPriority
frontend/services/dto/task-type.ts DTO TaskType
frontend/services/dto/task-group.ts DTO TaskGroup
frontend/services/dto/task.ts DTO Task
frontend/services/task-statuses.ts Service API statuts
frontend/services/task-efforts.ts Service API efforts
frontend/services/task-priorities.ts Service API priorités
frontend/services/task-types.ts Service API types
frontend/services/task-groups.ts Service API groupes
frontend/services/tasks.ts Service API tâches
frontend/pages/admin.vue Page admin avec onglets
frontend/pages/projects/[id].vue Page board projet (kanban + backlog)
frontend/components/AdminStatusTab.vue Tab CRUD statuts
frontend/components/AdminEffortTab.vue Tab CRUD efforts
frontend/components/AdminPriorityTab.vue Tab CRUD priorités
frontend/components/AdminTypeTab.vue Tab CRUD types
frontend/components/TaskStatusDrawer.vue Drawer statut
frontend/components/TaskEffortDrawer.vue Drawer effort
frontend/components/TaskPriorityDrawer.vue Drawer priorité
frontend/components/TaskTypeDrawer.vue Drawer type
frontend/components/TaskCard.vue Carte kanban
frontend/components/TaskDrawer.vue Drawer ticket
frontend/components/TaskGroupDrawer.vue Drawer groupe

Frontend (à modifier)

Fichier Modification
frontend/i18n/locales/fr.json Ajouter traductions task*
frontend/layouts/default.vue Ajouter lien Admin sidebar
frontend/pages/projects.vue Naviguer vers board au clic
frontend/services/projects.ts Ajouter getById()