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

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

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

  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

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