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