Update CLAUDE.md structure, add implementation plans, fix config/reference.php and MeProvider comment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
38 KiB
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
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
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) :
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
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
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
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) :
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
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
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
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 :
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 :
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 :
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
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 :
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 :
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
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 :
<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
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 :
<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 :
<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
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 :
<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 :
<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 :
<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
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 :
<!-- 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 :
{
"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
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
ClientetProjectdans 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
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
# 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
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
- Naviguer vers
http://localhost:8082/ - Se connecter avec
admin/admin - Cliquer "Projets" dans la sidebar → page avec 4 cartes colorées
- Cliquer "+ Ajouter un projet" → drawer s'ouvre, remplir et enregistrer
- Cliquer "Clients" dans la sidebar → page avec tableau de 3 clients
- Cliquer sur une ligne → drawer s'ouvre avec les données pré-remplies
- Cliquer "+ Ajouter un client" → drawer vide s'ouvre
Step 5: Commit final si ajustements
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 |