From 9ca9cb1d420d261f961136d1ffd1018311b0ad6a Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 11:17:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(front)=20:=20page=20r=C3=A9pertoire=20clie?= =?UTF-8?q?nts=20+=20datatable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Page /clients (route à plat) : MalioDataTable 6 colonnes (contact, téléphone formaté, codes catégories, badges sites), toggle « Voir les archivés » (état local), boutons Ajouter (manage) / Exporter (view, download xlsx), clic ligne vers le détail, empty state. - Composable useClientsRepository (wrapper de usePaginatedList) + util formatPhoneFR + clé i18n showArchived. - Contrat back : la liste client:read expose désormais les codes catégories (category:read) et les sites agrégés des adresses (site:read + Client::getSites) ; jointures anti N+1 dans createListQueryBuilder. Tests back + front. --- frontend/i18n/locales/fr.json | 1 + .../__tests__/useClientsRepository.spec.ts | 79 +++++++ .../composables/useClientsRepository.ts | 82 +++++++ .../commercial/pages/clients/index.vue | 211 ++++++++++++++++++ frontend/shared/utils/__tests__/phone.test.ts | 23 ++ frontend/shared/utils/phone.ts | 23 ++ .../Commercial/Domain/Entity/Client.php | 37 ++- .../Doctrine/DoctrineClientRepository.php | 9 + tests/Module/Commercial/Api/ClientApiTest.php | 59 ++++- 9 files changed, 519 insertions(+), 5 deletions(-) create mode 100644 frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts create mode 100644 frontend/modules/commercial/composables/useClientsRepository.ts create mode 100644 frontend/modules/commercial/pages/clients/index.vue create mode 100644 frontend/shared/utils/__tests__/phone.test.ts create mode 100644 frontend/shared/utils/phone.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 9b3b8b7..24a49c1 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -49,6 +49,7 @@ "title": "Répertoire clients", "add": "+ Ajouter", "export": "Exporter", + "showArchived": "Voir les archivés", "empty": "Aucun client pour l'instant.", "column": { "companyName": "Nom entreprise", diff --git a/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts b/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts new file mode 100644 index 0000000..ce1503b --- /dev/null +++ b/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { HydraCollection } from '~/shared/utils/api' +import type { Client } from '../useClientsRepository' + +// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter +// les appels declenches par usePaginatedList (que useClientsRepository enveloppe) +// et controler les reponses. Meme pattern que useCategoriesAdmin.spec.ts. +const mockGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +})) + +// Import APRES le stub pour que useApi soit bien resolu au top-level du module. +const { useClientsRepository } = await import('../useClientsRepository') + +/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */ +function makeHydra(total: number): HydraCollection { + return { totalItems: total, member: [] } +} + +describe('useClientsRepository', () => { + beforeEach(() => { + mockGet.mockReset() + // 25 items → 3 pages a 10/page : permet de tester la navigation page 2. + mockGet.mockResolvedValue(makeHydra(25)) + }) + + it('charge /clients sans includeArchived par defaut (clients actifs)', async () => { + const repo = useClientsRepository() + await repo.fetch() + + expect(repo.includeArchived.value).toBe(false) + expect(mockGet).toHaveBeenLastCalledWith( + '/clients', + { page: 1, itemsPerPage: 10 }, + expect.objectContaining({ toast: false }), + ) + }) + + it('pousse le filtre serveur includeArchived=true quand le toggle est actif', async () => { + const repo = useClientsRepository() + await repo.fetch() + await repo.setIncludeArchived(true) + + expect(repo.includeArchived.value).toBe(true) + expect(mockGet).toHaveBeenLastCalledWith( + '/clients', + { includeArchived: true, page: 1, itemsPerPage: 10 }, + expect.objectContaining({ toast: false }), + ) + }) + + it('retombe en page 1 lorsqu on bascule le toggle archives', async () => { + const repo = useClientsRepository() + await repo.fetch() + await repo.goToPage(2) + expect(repo.currentPage.value).toBe(2) + + await repo.setIncludeArchived(true) + expect(repo.currentPage.value).toBe(1) + }) + + it('retire le filtre (query propre) quand le toggle repasse a false', async () => { + const repo = useClientsRepository() + await repo.setIncludeArchived(true) + await repo.setIncludeArchived(false) + + expect(repo.includeArchived.value).toBe(false) + expect(mockGet).toHaveBeenLastCalledWith( + '/clients', + { page: 1, itemsPerPage: 10 }, + expect.objectContaining({ toast: false }), + ) + }) +}) diff --git a/frontend/modules/commercial/composables/useClientsRepository.ts b/frontend/modules/commercial/composables/useClientsRepository.ts new file mode 100644 index 0000000..2836837 --- /dev/null +++ b/frontend/modules/commercial/composables/useClientsRepository.ts @@ -0,0 +1,82 @@ +import { ref } from 'vue' +import { usePaginatedList } from '~/shared/composables/usePaginatedList' + +/** + * Site Starseed rattache a une adresse du client, tel qu'embarque en LISTE + * (groupe site:read) pour la colonne « Site(s) » du Repertoire (badges colores). + */ +export interface ClientSite { + id: number + name: string + color: string +} + +/** + * Categorie rattachee au client, embarquee en LISTE (groupe category:read). + * Seul le `code` (stable, MAJUSCULE — ERP-78) est affiche dans la colonne + * « Catégories ». Les autres champs sont presents mais non utilises ici. + */ +export interface ClientCategory { + code: string + name?: string +} + +/** + * Vue MINIMALE d'un client pour le Repertoire (datatable). Volontairement + * partielle : seuls les champs des colonnes + l'id (navigation) sont types ici. + * Le detail complet (onglets) est hors perimetre de cet ecran (ERP-62). + */ +export interface Client { + id: number + companyName: string + firstName: string | null + lastName: string | null + phonePrimary: string | null + email: string | null + categories: ClientCategory[] + sites: ClientSite[] + isArchived: boolean +} + +/** + * Repertoire clients (ERP-62) — simple enveloppe de `usePaginatedList` + * sur la ressource `/clients` (RG-13 : pagination serveur obligatoire ; jamais + * de chargement integral en memoire). + * + * N'ajoute qu'un seul comportement metier : le toggle « Voir les archivés ». + * Desactive par defaut (la liste n'expose que les clients actifs — RG-1.24). + * Active, il pousse le filtre serveur `?includeArchived=true` (consomme par le + * ClientProvider, RG-1.25) et — garantie de `usePaginatedList` — retombe en + * page 1. + * + * Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau + * est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de + * `usePaginatedList` (cf. sites.vue / categories.vue). Aucun reset au logout a + * gerer. + */ +export function useClientsRepository() { + // Etat local du toggle « Voir les archivés » — JAMAIS reflete dans l'URL + // (regle ABSOLUE n°6). + const includeArchived = ref(false) + + const list = usePaginatedList({ url: '/clients' }) + + /** + * Bascule l'inclusion des clients archives et relance la liste. La remise + * en page 1 est assuree par `setFilters` (usePaginatedList). Quand le toggle + * repasse a false, on RETIRE le filtre (valeur `undefined`) plutot que + * d'envoyer `includeArchived=false`, pour une query propre. + */ + async function setIncludeArchived(value: boolean): Promise { + includeArchived.value = value + await list.setFilters( + value ? { includeArchived: true } : { includeArchived: undefined }, + ) + } + + return { + ...list, + includeArchived, + setIncludeArchived, + } +} diff --git a/frontend/modules/commercial/pages/clients/index.vue b/frontend/modules/commercial/pages/clients/index.vue new file mode 100644 index 0000000..449f9a6 --- /dev/null +++ b/frontend/modules/commercial/pages/clients/index.vue @@ -0,0 +1,211 @@ + + + diff --git a/frontend/shared/utils/__tests__/phone.test.ts b/frontend/shared/utils/__tests__/phone.test.ts new file mode 100644 index 0000000..b18cb57 --- /dev/null +++ b/frontend/shared/utils/__tests__/phone.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest' +import { formatPhoneFR } from '../phone' + +describe('formatPhoneFR', () => { + it('formate un numero 10 chiffres en XX XX XX XX XX', () => { + expect(formatPhoneFR('0612345678')).toBe('06 12 34 56 78') + }) + + it('tolere une saisie deja pointee ou espacee', () => { + expect(formatPhoneFR('06.12.34.56.78')).toBe('06 12 34 56 78') + expect(formatPhoneFR('06 12 34 56 78')).toBe('06 12 34 56 78') + }) + + it('retourne une chaine vide pour une valeur vide ou nulle', () => { + expect(formatPhoneFR('')).toBe('') + expect(formatPhoneFR(null)).toBe('') + expect(formatPhoneFR(undefined)).toBe('') + }) + + it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => { + expect(formatPhoneFR('123')).toBe('12 3') + }) +}) diff --git a/frontend/shared/utils/phone.ts b/frontend/shared/utils/phone.ts new file mode 100644 index 0000000..e02a98e --- /dev/null +++ b/frontend/shared/utils/phone.ts @@ -0,0 +1,23 @@ +/** + * Formatage d'un numero de telephone francais en groupes de 2 chiffres + * (`XX XX XX XX XX`). + * + * Signature cible partagee avec le ticket 1.13 / ERP-66 : si ce dernier livre + * une version plus riche (validation, indicatif international), elle remplacera + * cette implementation minimale. En attendant, on couvre le besoin du Repertoire + * clients (ERP-62) : afficher un telephone lisible a partir de la valeur stockee + * en base (deja normalisee en 10 chiffres par le ClientProcessor, RG-1.20). + * + * - Ne garde que les chiffres puis groupe par 2 (tolere une saisie deja espacee + * ou pointee, ex: `06.12.34.56.78` ou `0612345678`). + * - Retourne une chaine vide si la valeur est vide/nulle (cellule vide propre). + */ +export function formatPhoneFR(value: string | null | undefined): string { + const digits = (value ?? '').replace(/\D/g, '') + if (digits.length === 0) { + return '' + } + + // Groupe par paquets de 2 ; un dernier groupe impair reste tel quel. + return digits.match(/.{1,2}/g)?.join(' ') ?? digits +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index bf1d22e..4ce07e4 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\CategoryInterface; +use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use DateTimeImmutable; @@ -58,7 +59,11 @@ use Symfony\Component\Validator\Constraints as Assert; operations: [ new GetCollection( security: "is_granted('commercial.clients.view')", - normalizationContext: ['groups' => ['client:read', 'default:read']], + // La liste embarque les categories (avec leur code, groupe + // category:read) et les sites agreges des adresses (groupe + // site:read) pour alimenter les colonnes « Catégories » et + // « Site(s) » du Repertoire (ERP-62). Cf. getSites() plus bas. + normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']], provider: ClientProvider::class, ), new Get( @@ -72,13 +77,15 @@ use Symfony\Component\Validator\Constraints as Assert; 'client_contact:read', 'client_address:read', 'client_rib:read', + 'category:read', + 'site:read', 'default:read', ]], provider: ClientProvider::class, ), new Post( security: "is_granted('commercial.clients.manage')", - normalizationContext: ['groups' => ['client:read', 'default:read']], + normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']], denormalizationContext: ['groups' => ['client:write:main']], processor: ClientProcessor::class, ), @@ -96,7 +103,7 @@ use Symfony\Component\Validator\Constraints as Assert; // autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les // champs accounting exigent accounting.manage, isArchived exige // archive, le reste (main/information) exige manage. - normalizationContext: ['groups' => ['client:read', 'default:read']], + normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']], denormalizationContext: ['groups' => [ 'client:write:main', 'client:write:information', @@ -651,6 +658,30 @@ class Client implements TimestampableInterface, BlamableInterface return $this; } + /** + * Sites distincts rattaches a au moins une adresse du client (RG-1.10). + * Le Client ne porte pas de sites en propre : ils vivent sur les adresses. + * Agrege en lecture seule pour la colonne « Site(s) » du Repertoire (badges + * colores) — expose en LISTE via le groupe client:read (les adresses + * completes restent reservees au detail, client:item:read). + * + * @return list + */ + #[Groups(['client:read'])] + public function getSites(): array + { + $sites = []; + foreach ($this->addresses as $address) { + foreach ($address->getSites() as $site) { + // Deduplication par identite d'objet : un meme site peut etre + // rattache a plusieurs adresses du client. + $sites[spl_object_id($site)] = $site; + } + } + + return array_values($sites); + } + /** @return Collection */ #[Groups(['client:item:read'])] public function getRibs(): Collection diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php index d2f7fb2..a29a252 100644 --- a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php @@ -37,6 +37,15 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client ?string $categoryCode = null, ): QueryBuilder { $qb = $this->createQueryBuilder('c') + // Jointures + addSelect pour hydrater en une seule requete les + // collections affichees par le Repertoire (colonnes Catégories / + // Site(s)) : sans cela, la serialisation declenche un N+1 (une + // requete par client, puis par adresse). Le Paginator ORM + // (fetchJoinCollection: true, cf. ClientProvider) gere le COUNT + // malgre ces jointures to-many. + ->leftJoin('c.categories', 'cat')->addSelect('cat') + ->leftJoin('c.addresses', 'addr')->addSelect('addr') + ->leftJoin('addr.sites', 'site')->addSelect('site') ->andWhere('c.deletedAt IS NULL') ->orderBy('c.companyName', 'ASC') ; diff --git a/tests/Module/Commercial/Api/ClientApiTest.php b/tests/Module/Commercial/Api/ClientApiTest.php index 989c7f0..f91ae03 100644 --- a/tests/Module/Commercial/Api/ClientApiTest.php +++ b/tests/Module/Commercial/Api/ClientApiTest.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Tests\Module\Commercial\Api; +use App\Module\Commercial\Domain\Entity\ClientAddress; +use App\Module\Sites\Domain\Entity\Site; + /** * Tests fonctionnels de l'API /api/clients (M1) — branche ERP-55. * @@ -177,8 +180,8 @@ final class ClientApiTest extends AbstractCommercialApiTestCase public function testPostBrokerReferencingNonBrokerReturns422(): void { - $client = $this->createAdminClient(); - $cat = $this->createCategory('SECTEUR'); + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); $notBroker = $this->seedClient('Pas Un Courtier', false, 'SECTEUR'); $client->request('POST', '/api/clients', [ @@ -325,4 +328,56 @@ final class ClientApiTest extends AbstractCommercialApiTestCase self::assertArrayHasKey('addresses', $data); self::assertArrayHasKey('ribs', $data); } + + /** + * ERP-62 : la LISTE doit alimenter les colonnes « Catégories » (codes) et + * « Site(s) » (badges name + color) du Repertoire. On verifie donc que la + * collection embarque le `code` de chaque categorie et les sites agreges des + * adresses (accessoire Client::getSites()). + */ + public function testListEmbedsCategoryCodesAndAggregatedSites(): void + { + $client = $this->createAdminClient(); + + // Client seede + une adresse rattachee a un site (fixtures Sites). + $seed = $this->seedClient('Embed List Co', false, 'DISTRIBUTEUR'); + $em = $this->getEm(); + $site = $em->getRepository(Site::class)->findOneBy([]); + self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Site(s).'); + + $address = new ClientAddress(); + $address->setClient($seed); + $address->setPostalCode('86100'); + $address->setCity('Châtellerault'); + $address->setStreet('1 rue du Test'); + $address->addSite($site); + $em->persist($address); + $em->flush(); + + $member = $client->request('GET', '/api/clients?pagination=false', [ + 'headers' => ['Accept' => self::LD], + ])->toArray()['member']; + + $row = null; + foreach ($member as $candidate) { + if ('EMBED LIST CO' === $candidate['companyName']) { + $row = $candidate; + + break; + } + } + self::assertNotNull($row, 'Le client seede doit figurer dans la liste.'); + + // Colonne « Catégories » : chaque categorie embarquee porte son code. + self::assertNotEmpty($row['categories']); + self::assertArrayHasKey('code', $row['categories'][0]); + self::assertSame('DISTRIBUTEUR', $row['categories'][0]['code']); + + // Colonne « Site(s) » : sites agreges des adresses, avec name + color. + self::assertArrayHasKey('sites', $row); + self::assertNotEmpty($row['sites']); + self::assertArrayHasKey('name', $row['sites'][0]); + self::assertArrayHasKey('color', $row['sites'][0]); + self::assertSame($site->getName(), $row['sites'][0]['name']); + } }