Update CLAUDE.md structure, add implementation plans, fix config/reference.php and MeProvider comment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2422 lines
67 KiB
Markdown
2422 lines
67 KiB
Markdown
# 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()` |
|