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