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

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

1355 lines
38 KiB
Markdown

# CRUD Clients & Projets - Plan d'implémentation
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Implémenter le CRUD complet Client et Projet (backend API Platform + frontend Nuxt) avec pages dédiées, drawers latéraux pour ajout/modification, et fixtures de données.
**Architecture:** Deux entités Doctrine (Client, Project) avec relation ManyToOne optionnelle Project→Client. API Platform gère le CRUD via les attributs sur les entités (pas de controllers). Frontend avec pages dédiées : tableau pour les clients, cartes colorées pour les projets, drawers droits pour les formulaires.
**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / PostgreSQL (backend) — Nuxt 4 / Vue 3 / Pinia / Tailwind CSS / Malio UI Layer (frontend)
---
## Task 1 : Entité Client (Backend)
**Files:**
- Create: `src/Entity/Client.php`
- Create: `src/Repository/ClientRepository.php`
**Step 1: Créer le repository Client**
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Client;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ClientRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Client::class);
}
}
```
**Step 2: Créer l'entité Client**
```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\ClientRepository;
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' => ['client:read']],
denormalizationContext: ['groups' => ['client:write']],
order: ['name' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: ClientRepository::class)]
class Client
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client:read', 'project:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['client:read', 'client:write', 'project:read'])]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $phone = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $city = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $postalCode = null;
/** @var Collection<int, Project> */
#[ORM\OneToMany(targetEntity: Project::class, mappedBy: 'client')]
private Collection $projects;
public function __construct()
{
$this->projects = new ArrayCollection();
}
// Getters et setters pour chaque propriété (getId, getName/setName, getEmail/setEmail,
// getPhone/setPhone, getStreet/setStreet, getCity/setCity, getPostalCode/setPostalCode,
// getProjects)
// Suivre le même pattern fluent (return $this) que User.php
}
```
**Step 3: Générer et exécuter la migration**
Run (dans le container PHP) :
```bash
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
```
Expected: Migration créée et exécutée, table `client` créée en base.
**Step 4: Vérifier l'API**
Run: `curl -s http://localhost:8082/api/clients | head -20` (après login)
Expected: Réponse Hydra vide `{"hydra:member":[],"hydra:totalItems":0}`
**Step 5: Commit**
```bash
git add src/Entity/Client.php src/Repository/ClientRepository.php migrations/
git commit -m "feat : add Client entity with CRUD API"
```
---
## Task 2 : Entité Project (Backend)
**Files:**
- Create: `src/Entity/Project.php`
- Create: `src/Repository/ProjectRepository.php`
**Step 1: Créer le repository Project**
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Project;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ProjectRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Project::class);
}
}
```
**Step 2: Créer l'entité Project**
```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\ProjectRepository;
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' => ['project:read']],
denormalizationContext: ['groups' => ['project:write']],
order: ['name' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
class Project
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['project:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['project:read', 'project:write'])]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['project:read', 'project:write'])]
private ?string $description = null;
#[ORM\Column(length: 7)]
#[Groups(['project:read', 'project:write'])]
private ?string $color = '#222783';
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'projects')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['project:read', 'project:write'])]
private ?Client $client = null;
// Getters et setters pour chaque propriété (getId, getName/setName, getDescription/setDescription,
// getColor/setColor, getClient/setClient)
// Suivre le même pattern fluent (return $this) que User.php
}
```
**Note importante pour API Platform :** Le champ `client` en écriture accepte un IRI (ex: `"/api/clients/1"`) ou `null`.
**Step 3: Générer et exécuter la migration**
Run (dans le container PHP) :
```bash
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
```
Expected: Migration créée, table `project` avec foreign key vers `client`.
**Step 4: Commit**
```bash
git add src/Entity/Project.php src/Repository/ProjectRepository.php migrations/
git commit -m "feat : add Project entity with CRUD API and Client relation"
```
---
## Task 3 : Fixtures Clients & Projets
**Files:**
- Modify: `src/DataFixtures/AppFixtures.php`
**Step 1: Ajouter les fixtures**
Modifier `AppFixtures.php` pour ajouter des clients et projets après la création du user admin :
```php
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\Client;
use App\Entity\Project;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class AppFixtures extends Fixture
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
public function load(ObjectManager $manager): void
{
// User admin
$admin = new User();
$admin->setUsername('admin');
$admin->setRoles(['ROLE_ADMIN']);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$manager->persist($admin);
// Clients
$clientLiot = new Client();
$clientLiot->setName('LIOT');
$clientLiot->setEmail('contact@liot.fr');
$clientLiot->setPhone('05 50 50 50 50');
$clientLiot->setStreet('14 allée d\'argenson');
$clientLiot->setCity('Poitiers');
$clientLiot->setPostalCode('86100');
$manager->persist($clientLiot);
$clientAcme = new Client();
$clientAcme->setName('ACME Corp');
$clientAcme->setEmail('contact@acme.com');
$clientAcme->setPhone('01 23 45 67 89');
$clientAcme->setStreet('10 rue de la Paix');
$clientAcme->setCity('Paris');
$clientAcme->setPostalCode('75002');
$manager->persist($clientAcme);
$clientNova = new Client();
$clientNova->setName('Nova Tech');
$clientNova->setEmail('info@novatech.io');
$clientNova->setPhone('04 56 78 90 12');
$clientNova->setStreet('5 avenue Jean Jaurès');
$clientNova->setCity('Lyon');
$clientNova->setPostalCode('69007');
$manager->persist($clientNova);
// Projets
$colors = ['#222783', '#E91E63', '#4A90D9', '#7E57C2', '#26A69A', '#FDD835', '#8BC34A', '#FF7043'];
$projectSirh = new Project();
$projectSirh->setName('SIRH');
$projectSirh->setDescription('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer ac blandit turpis.');
$projectSirh->setColor($colors[0]);
$projectSirh->setClient($clientLiot);
$manager->persist($projectSirh);
$projectCrm = new Project();
$projectCrm->setName('CRM');
$projectCrm->setDescription('Gestion de la relation client et suivi commercial.');
$projectCrm->setColor($colors[1]);
$projectCrm->setClient($clientAcme);
$manager->persist($projectCrm);
$projectErp = new Project();
$projectErp->setName('ERP');
$projectErp->setDescription('Planification des ressources et gestion des stocks.');
$projectErp->setColor($colors[2]);
$projectErp->setClient($clientNova);
$manager->persist($projectErp);
$projectInterne = new Project();
$projectInterne->setName('Site vitrine');
$projectInterne->setDescription('Refonte du site web corporate.');
$projectInterne->setColor($colors[4]);
$projectInterne->setClient(null);
$manager->persist($projectInterne);
$manager->flush();
}
}
```
**Step 2: Recharger les fixtures**
Run: `make fixtures` ou dans le container : `php bin/console doctrine:fixtures:load --no-interaction`
Expected: 1 user, 3 clients, 4 projets créés.
**Step 3: Commit**
```bash
git add src/DataFixtures/AppFixtures.php
git commit -m "feat : add Client and Project fixtures"
```
---
## Task 4 : DTOs & Service Client (Frontend)
**Files:**
- Create: `frontend/services/dto/client.ts`
- Create: `frontend/services/clients.ts`
- Modify: `frontend/utils/api.ts` (créer si inexistant — utilitaire Hydra)
**Step 1: Créer l'utilitaire Hydra**
Créer `frontend/utils/api.ts` :
```typescript
export type HydraCollection<T> = {
'hydra:member': T[]
'hydra:totalItems': number
}
export function extractHydraMembers<T>(response: HydraCollection<T>): T[] {
return response['hydra:member'] ?? []
}
```
**Step 2: Créer le DTO Client**
Créer `frontend/services/dto/client.ts` :
```typescript
export type Client = {
id: number
'@id'?: string
name: string
email: string | null
phone: string | null
street: string | null
city: string | null
postalCode: string | null
}
export type ClientWrite = {
name: string
email: string | null
phone: string | null
street: string | null
city: string | null
postalCode: string | null
}
```
**Step 3: Créer le service Client**
Créer `frontend/services/clients.ts` :
```typescript
import type { Client, ClientWrite } from './dto/client'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useClientService() {
const api = useApi()
async function getAll(): Promise<Client[]> {
const data = await api.get<HydraCollection<Client>>('/clients')
return extractHydraMembers(data)
}
async function create(payload: ClientWrite): Promise<Client> {
return api.post<Client>('/clients', payload as Record<string, unknown>, {
toastSuccessKey: 'clients.created',
})
}
async function update(id: number, payload: Partial<ClientWrite>): Promise<Client> {
return api.patch<Client>(`/clients/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'clients.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/clients/${id}`, {}, {
toastSuccessKey: 'clients.deleted',
})
}
return { getAll, create, update, remove }
}
```
**Step 4: Commit**
```bash
git add frontend/utils/api.ts frontend/services/dto/client.ts frontend/services/clients.ts
git commit -m "feat : add Client DTO, service and Hydra utils (frontend)"
```
---
## Task 5 : DTOs & Service Project (Frontend)
**Files:**
- Create: `frontend/services/dto/project.ts`
- Create: `frontend/services/projects.ts`
**Step 1: Créer le DTO Project**
Créer `frontend/services/dto/project.ts` :
```typescript
import type { Client } from './client'
export type Project = {
id: number
'@id'?: string
name: string
description: string | null
color: string
client: Client | null
}
export type ProjectWrite = {
name: string
description: string | null
color: string
client: string | null // IRI : "/api/clients/1" ou null
}
```
**Step 2: Créer le service Project**
Créer `frontend/services/projects.ts` :
```typescript
import type { Project, ProjectWrite } from './dto/project'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useProjectService() {
const api = useApi()
async function getAll(): Promise<Project[]> {
const data = await api.get<HydraCollection<Project>>('/projects')
return extractHydraMembers(data)
}
async function create(payload: ProjectWrite): Promise<Project> {
return api.post<Project>('/projects', payload as Record<string, unknown>, {
toastSuccessKey: 'projects.created',
})
}
async function update(id: number, payload: Partial<ProjectWrite>): Promise<Project> {
return api.patch<Project>(`/projects/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'projects.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/projects/${id}`, {}, {
toastSuccessKey: 'projects.deleted',
})
}
return { getAll, create, update, remove }
}
```
**Step 3: Commit**
```bash
git add frontend/services/dto/project.ts frontend/services/projects.ts
git commit -m "feat : add Project DTO and service (frontend)"
```
---
## Task 6 : Composant Drawer réutilisable
**Files:**
- Create: `frontend/components/AppDrawer.vue`
**Step 1: Créer le composant Drawer**
Créer `frontend/components/AppDrawer.vue` :
```vue
<template>
<Teleport to="body">
<Transition name="drawer">
<div
v-if="modelValue"
class="fixed inset-0 z-40 flex justify-end"
>
<div
class="absolute inset-0 bg-black/30"
@click="close"
/>
<div
class="relative z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
>
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
<h2 class="text-lg font-bold text-neutral-900">{{ title }}</h2>
<button
type="button"
class="rounded p-1 text-neutral-400 hover:text-neutral-600"
@click="close"
>
<Icon name="mdi:close" size="24" />
</button>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4">
<slot />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean
title: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
function close() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.2s ease;
}
.drawer-enter-active > div:last-child,
.drawer-leave-active > div:last-child {
transition: transform 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from > div:last-child,
.drawer-leave-to > div:last-child {
transform: translateX(100%);
}
</style>
```
**Step 2: Vérifier le rendu**
Tester en important temporairement dans une page existante (optionnel).
**Step 3: Commit**
```bash
git add frontend/components/AppDrawer.vue
git commit -m "feat : add reusable AppDrawer component"
```
---
## Task 7 : Page Clients (Frontend)
**Files:**
- Create: `frontend/pages/clients.vue`
- Create: `frontend/components/ClientDrawer.vue`
**Step 1: Créer le formulaire drawer Client**
Créer `frontend/components/ClientDrawer.vue` :
```vue
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un client' : 'Ajouter un client'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
label="Nom"
input-class="w-full"
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
@blur="touched.name = true"
/>
<MalioInputText
v-model="form.email"
label="Email"
input-class="w-full"
@blur="touched.email = true"
/>
<MalioInputText
v-model="form.phone"
label="Téléphone"
input-class="w-full"
/>
<MalioInputText
v-model="form.street"
label="Rue"
input-class="w-full"
/>
<MalioInputText
v-model="form.city"
label="Ville"
input-class="w-full"
/>
<MalioInputText
v-model="form.postalCode"
label="Code Postal"
input-class="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 { Client, ClientWrite } from '~/services/dto/client'
const props = defineProps<{
modelValue: boolean
client: Client | 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.client)
const isSubmitting = ref(false)
const form = reactive({
name: '',
email: '',
phone: '',
street: '',
city: '',
postalCode: '',
})
const touched = reactive({
name: false,
email: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.client) {
form.name = props.client.name ?? ''
form.email = props.client.email ?? ''
form.phone = props.client.phone ?? ''
form.street = props.client.street ?? ''
form.city = props.client.city ?? ''
form.postalCode = props.client.postalCode ?? ''
} else {
form.name = ''
form.email = ''
form.phone = ''
form.street = ''
form.city = ''
form.postalCode = ''
}
touched.name = false
touched.email = false
}
})
const { create, update } = useClientService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
const payload: ClientWrite = {
name: form.name.trim(),
email: form.email.trim() || null,
phone: form.phone.trim() || null,
street: form.street.trim() || null,
city: form.city.trim() || null,
postalCode: form.postalCode.trim() || null,
}
if (isEditing.value && props.client) {
await update(props.client.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
```
**Step 2: Créer la page Clients**
Créer `frontend/pages/clients.vue` :
```vue
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-neutral-900">Clients</h1>
<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 client
</button>
</div>
<div class="mt-6 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">Nom</th>
<th class="px-4 py-3 font-semibold text-neutral-700">Email</th>
<th class="px-4 py-3 font-semibold text-neutral-700">Adresse</th>
<th class="px-4 py-3 font-semibold text-neutral-700">Téléphone</th>
</tr>
</thead>
<tbody>
<tr
v-for="client in clients"
:key="client.id"
class="border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer"
@click="openEdit(client)"
>
<td class="px-4 py-3 font-semibold text-primary-500">{{ client.name }}</td>
<td class="px-4 py-3 text-primary-500">{{ client.email ?? '-' }}</td>
<td class="px-4 py-3 text-neutral-700">{{ formatAddress(client) }}</td>
<td class="px-4 py-3 text-primary-500">{{ client.phone ?? '-' }}</td>
</tr>
<tr v-if="clients.length === 0 && !isLoading">
<td colspan="4" class="px-4 py-8 text-center text-neutral-400">
Aucun client trouvé.
</td>
</tr>
</tbody>
</table>
</div>
<ClientDrawer
v-model="drawerOpen"
:client="selectedClient"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Client } from '~/services/dto/client'
useHead({ title: 'Clients' })
const { getAll } = useClientService()
const clients = ref<Client[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedClient = ref<Client | null>(null)
async function loadClients() {
isLoading.value = true
try {
clients.value = await getAll()
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedClient.value = null
drawerOpen.value = true
}
function openEdit(client: Client) {
selectedClient.value = client
drawerOpen.value = true
}
function formatAddress(client: Client): string {
return [client.street, client.postalCode, client.city]
.filter(Boolean)
.join(', ') || '-'
}
async function onSaved() {
await loadClients()
}
onMounted(() => {
loadClients()
})
</script>
```
**Step 3: Vérifier le rendu**
Run: `make dev-nuxt` puis naviguer vers `/clients`
Expected: Tableau vide (ou avec données si fixtures chargées), bouton "+ Ajouter un client", drawer s'ouvre au clic.
**Step 4: Commit**
```bash
git add frontend/pages/clients.vue frontend/components/ClientDrawer.vue
git commit -m "feat : add Clients page with table and drawer form"
```
---
## Task 8 : Page Projets (Frontend)
**Files:**
- Create: `frontend/pages/projects.vue`
- Create: `frontend/components/ProjectDrawer.vue`
- Create: `frontend/components/ColorPicker.vue`
**Step 1: Créer le composant ColorPicker**
Créer `frontend/components/ColorPicker.vue` :
```vue
<template>
<div>
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p>
<div class="flex flex-wrap gap-3">
<button
v-for="color in colors"
:key="color"
type="button"
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110"
:class="modelValue === color ? 'border-neutral-900 scale-110' : 'border-transparent'"
:style="{ backgroundColor: color }"
@click="emit('update:modelValue', color)"
/>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const colors = [
'#26A69A', '#E91E63', '#4A90D9', '#7E57C2',
'#8BC34A', '#FDD835', '#80DEEA', '#FF7043',
]
</script>
```
**Step 2: Créer le formulaire drawer Project**
Créer `frontend/components/ProjectDrawer.vue` :
```vue
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un projet' : 'Ajouter un projet'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
label="Titre"
input-class="w-full"
:error="touched.name && !form.name.trim() ? 'Le titre est requis' : ''"
@blur="touched.name = true"
/>
<MalioInputTextArea
v-model="form.description"
label="Description"
:size="3"
/>
<MalioSelect
v-model="form.clientId"
:options="clientOptions"
label="Client"
empty-option-label="Aucun client"
min-width="w-full"
/>
<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 { Project, ProjectWrite } from '~/services/dto/project'
import type { Client } from '~/services/dto/client'
const props = defineProps<{
modelValue: boolean
project: Project | null
clients: Client[]
}>()
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.project)
const isSubmitting = ref(false)
const form = reactive({
name: '',
description: '',
color: '#222783',
clientId: null as number | null,
})
const touched = reactive({
name: false,
})
const clientOptions = computed(() =>
props.clients.map(c => ({ label: c.name, value: c.id }))
)
watch(() => props.modelValue, (open) => {
if (open) {
if (props.project) {
form.name = props.project.name ?? ''
form.description = props.project.description ?? ''
form.color = props.project.color ?? '#222783'
form.clientId = props.project.client?.id ?? null
} else {
form.name = ''
form.description = ''
form.color = '#222783'
form.clientId = null
}
touched.name = false
}
})
const { create, update } = useProjectService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
const payload: ProjectWrite = {
name: form.name.trim(),
description: form.description.trim() || null,
color: form.color,
client: form.clientId ? `/api/clients/${form.clientId}` : null,
}
if (isEditing.value && props.project) {
await update(props.project.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
```
**Step 3: Créer la page Projets**
Créer `frontend/pages/projects.vue` :
```vue
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-neutral-900">Projets</h1>
<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 projet
</button>
</div>
<div class="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div
v-for="project in projects"
:key="project.id"
class="cursor-pointer rounded-lg border border-neutral-200 bg-white shadow-sm transition hover:shadow-md"
@click="openEdit(project)"
>
<div class="h-2 rounded-t-lg" :style="{ backgroundColor: project.color }" />
<div class="p-4">
<h3 class="text-md font-bold text-primary-500">{{ project.name }}</h3>
<p class="mt-2 text-sm text-neutral-600 line-clamp-4">
{{ project.description ?? '' }}
</p>
</div>
</div>
<div
v-if="projects.length === 0 && !isLoading"
class="col-span-full py-12 text-center text-neutral-400"
>
Aucun projet trouvé.
</div>
</div>
<ProjectDrawer
v-model="drawerOpen"
:project="selectedProject"
:clients="clients"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Project } from '~/services/dto/project'
import type { Client } from '~/services/dto/client'
useHead({ title: 'Projets' })
const projectService = useProjectService()
const clientService = useClientService()
const projects = ref<Project[]>([])
const clients = ref<Client[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedProject = ref<Project | null>(null)
async function loadData() {
isLoading.value = true
try {
const [p, c] = await Promise.all([
projectService.getAll(),
clientService.getAll(),
])
projects.value = p
clients.value = c
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedProject.value = null
drawerOpen.value = true
}
function openEdit(project: Project) {
selectedProject.value = project
drawerOpen.value = true
}
async function onSaved() {
await loadData()
}
onMounted(() => {
loadData()
})
</script>
```
**Step 4: Supprimer l'ancienne page project-list si elle existe**
Vérifier si `frontend/pages/project-list.vue` existe. Si oui, la supprimer (c'était un placeholder).
**Step 5: Commit**
```bash
git add frontend/components/ColorPicker.vue frontend/components/ProjectDrawer.vue frontend/pages/projects.vue
git rm frontend/pages/project-list.vue 2>/dev/null || true
git commit -m "feat : add Projects page with cards and drawer form"
```
---
## Task 9 : Navigation Sidebar + Traductions i18n
**Files:**
- Modify: `frontend/layouts/default.vue`
- Modify: `frontend/i18n/locales/fr.json`
**Step 1: Mettre à jour la sidebar**
Dans `frontend/layouts/default.vue`, ajouter le lien "Clients" sous "Projets" dans la nav :
```html
<!-- Ajouter après le NuxtLink Projets existant -->
<NuxtLink
to="/clients"
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:account-group-outline" size="24"/>
<span class="self-baseline text-md">Clients</span>
</NuxtLink>
```
Aussi, mettre à jour le lien Projets pour pointer vers `/projects` (au lieu de `/project-list`) :
Changer `to="/project-list"``to="/projects"`
**Step 2: Ajouter les traductions**
Modifier `frontend/i18n/locales/fr.json` pour ajouter les clés de toast :
```json
{
"errors": {
"http": {
"get": "Impossible de récupérer les données.",
"post": "Impossible de créer la ressource.",
"put": "Impossible de mettre à jour la ressource.",
"patch": "Impossible de mettre à jour la ressource.",
"delete": "Impossible de supprimer la ressource."
},
"auth": {
"login": "Identifiants invalides.",
"logout": "Impossible de se déconnecter.",
"session": "Session expirée"
}
},
"success": {
"auth": {
"login": "Connexion réussie.",
"logout": "Déconnexion réussie."
}
},
"clients": {
"created": "Client créé avec succès.",
"updated": "Client mis à jour avec succès.",
"deleted": "Client supprimé avec succès."
},
"projects": {
"created": "Projet créé avec succès.",
"updated": "Projet mis à jour avec succès.",
"deleted": "Projet supprimé avec succès."
}
}
```
**Step 3: Commit**
```bash
git add frontend/layouts/default.vue frontend/i18n/locales/fr.json
git commit -m "feat : add Clients nav link and i18n translations"
```
---
## Task 10 : Mise à jour CLAUDE.md
**Files:**
- Modify: `CLAUDE.md`
**Step 1: Mettre à jour CLAUDE.md**
Ajouter dans la section structure les nouveaux fichiers et patterns. Points à ajouter/mettre à jour :
- Ajouter `Client` et `Project` dans les entités documentées
- Ajouter les fixtures complètes (3 clients, 4 projets)
- Documenter le pattern Drawer (AppDrawer + formulaires spécifiques)
- Documenter les services frontend (useClientService, useProjectService)
- Documenter les DTOs (Client, ClientWrite, Project, ProjectWrite)
- Documenter l'utilitaire Hydra (`utils/api.ts`)
- Documenter le composant ColorPicker
- Mettre à jour la structure des dossiers
**Step 2: Commit**
```bash
git add CLAUDE.md
git commit -m "docs : update CLAUDE.md with Client and Project architecture"
```
---
## Task 11 : Tests manuels de bout en bout
**Step 1: Recharger les fixtures**
Run: `make db-reset` ou `make fixtures`
Expected: BDD reset avec admin + 3 clients + 4 projets
**Step 2: Tester l'API Client via curl**
```bash
# Login
curl -c cookies.txt -X POST http://localhost:8082/login_check \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin"}'
# GET clients
curl -b cookies.txt http://localhost:8082/api/clients
```
Expected: Liste de 3 clients au format Hydra
**Step 3: Tester l'API Project via curl**
```bash
curl -b cookies.txt http://localhost:8082/api/projects
```
Expected: Liste de 4 projets au format Hydra, chaque projet avec `client` imbriqué ou null
**Step 4: Tester le frontend**
1. Naviguer vers `http://localhost:8082/`
2. Se connecter avec `admin` / `admin`
3. Cliquer "Projets" dans la sidebar → page avec 4 cartes colorées
4. Cliquer "+ Ajouter un projet" → drawer s'ouvre, remplir et enregistrer
5. Cliquer "Clients" dans la sidebar → page avec tableau de 3 clients
6. Cliquer sur une ligne → drawer s'ouvre avec les données pré-remplies
7. Cliquer "+ Ajouter un client" → drawer vide s'ouvre
**Step 5: Commit final si ajustements**
```bash
git add -A
git commit -m "fix : adjustments after manual testing"
```
---
## Résumé des commits prévus
| # | Message | Scope |
|---|---------|-------|
| 1 | `feat : add Client entity with CRUD API` | Backend |
| 2 | `feat : add Project entity with CRUD API and Client relation` | Backend |
| 3 | `feat : add Client and Project fixtures` | Backend |
| 4 | `feat : add Client DTO, service and Hydra utils (frontend)` | Frontend |
| 5 | `feat : add Project DTO and service (frontend)` | Frontend |
| 6 | `feat : add reusable AppDrawer component` | Frontend |
| 7 | `feat : add Clients page with table and drawer form` | Frontend |
| 8 | `feat : add Projects page with cards and drawer form` | Frontend |
| 9 | `feat : add Clients nav link and i18n translations` | Frontend |
| 10 | `docs : update CLAUDE.md with Client and Project architecture` | Docs |