# 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
<?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
<?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**

```bash
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
<?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**

```bash
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
<?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**

```bash
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
<?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**

```bash
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
<?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**

```bash
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
<?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 :

```php
// 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**

```bash
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**

```bash
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 :

```php
// --- 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**

```bash
make db-reset
```

**Step 4: Commit**

```bash
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**

```typescript
// 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
}
```

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

export type TaskEffortWrite = {
    label: string
}
```

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

export type TaskPriorityWrite = {
    label: string
    color: string
}
```

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

export type TaskTypeWrite = {
    label: string
    color: string
}
```

```typescript
// 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
}
```

```typescript
// 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**

```bash
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()` :

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

```typescript
// 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**

```bash
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 :

```json
{
    "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**

```bash
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**

```vue
<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**

```bash
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**

```vue
<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**

```vue
<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**

```bash
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**

```bash
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**

```bash
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**

```bash
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 :

```vue
<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**

```bash
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.

```vue
<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()` :

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

Et `getByProject` dans `useTaskGroupService()` :

```typescript
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**

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

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

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

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

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

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

```bash
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**

```bash
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**

```bash
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()` |
