Update CLAUDE.md structure, add implementation plans, fix config/reference.php and MeProvider comment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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é i18ntaskEffortsuseTaskPriorityService()→/task_priorities→ clé i18ntaskPrioritiesuseTaskTypeService()→/task_types→ clé i18ntaskTypesuseTaskGroupService()→/task_groups→ clé i18ntaskGroupsuseTaskService()→/tasks→ clé i18ntasks(+ paramètre queryprojectpour 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() |