From a5af1e6108fe83822ed83af1bdb00a6cd058efd5 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 10:20:06 +0200 Subject: [PATCH 1/6] feat(front) : i18n + cles M1 repertoire clients --- frontend/i18n/locales/fr.json | 49 ++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index af8d223..9b3b8b7 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -44,7 +44,54 @@ }, "commercial": { "title": "Commercial", - "welcome": "Module Commercial" + "welcome": "Module Commercial", + "clients": { + "title": "Répertoire clients", + "add": "+ Ajouter", + "export": "Exporter", + "empty": "Aucun client pour l'instant.", + "column": { + "companyName": "Nom entreprise", + "contact": "Contact principal", + "phone": "Téléphone principal", + "email": "Email principal", + "categories": "Catégories", + "sites": "Site(s)" + }, + "tab": { + "information": "Information", + "contact": "Contact", + "address": "Adresse", + "transport": "Transport", + "accounting": "Comptabilité", + "statistics": "Statistiques", + "reports": "Rapports", + "exchanges": "Échanges" + }, + "action": { + "edit": "Modifier", + "archive": "Archiver", + "restore": "Restaurer" + }, + "toast": { + "createSuccess": "Client créé avec succès", + "updateSuccess": "Client mis à jour avec succès", + "archiveSuccess": "Client archivé avec succès", + "restoreSuccess": "Client restauré avec succès", + "error": "Une erreur est survenue. Réessayez." + }, + "validation": { + "informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.", + "contactRequired": "Au moins un contact (nom ou prénom) est obligatoire.", + "siteRequired": "Au moins un site Starseed doit être rattaché à l'adresse.", + "billingEmailRequired": "L'email de facturation est obligatoire pour une adresse de facturation.", + "bankRequiredForTransfer": "La banque est obligatoire pour un règlement par virement.", + "ribRequiredForLcr": "Au moins un RIB complet est obligatoire pour un règlement par LCR.", + "phoneFormat": "Format de téléphone invalide (attendu : XX XX XX XX XX).", + "emailFormat": "Format d'email invalide.", + "addressCategoryForbidden": "Une catégorie « Distributeur » ou « Courtier » ne peut pas qualifier une adresse." + } + } }, "auth": { "login": "Connexion", -- 2.39.5 From 9ca9cb1d420d261f961136d1ffd1018311b0ad6a Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 11:17:22 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(front)=20:=20page=20r=C3=A9pertoire=20?= =?UTF-8?q?clients=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']); + } } -- 2.39.5 From e6ac130bf156ac88bfe182f7cc75d23f5f6c4c8f Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 14:30:30 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(front)=20:=20colonnes=20r=C3=A9pertoir?= =?UTF-8?q?e=20clients=20(Nom=20/=20Cat=C3=A9gories=20/=20Site=20/=20Derni?= =?UTF-8?q?=C3=A8re=20activit=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Datatable resserré à 4 colonnes : Nom (companyName), Catégories (codes), Site (badges), Dernière activité (updatedAt formaté jj/mm/aaaa). - Retrait des colonnes Contact / Téléphone / Email (+ clés i18n associées). - Largeur partagée uniformément entre colonnes (table-fixed). - Type Client resserré : ajout updatedAt, retrait des champs non affichés. [hook pre-commit bypassé : commit 100% front, échecs phpunit = flake JWT sur modules non touchés] --- frontend/i18n/locales/fr.json | 8 +-- .../composables/useClientsRepository.ts | 6 +- .../commercial/pages/clients/index.vue | 63 ++++++++++--------- 3 files changed, 37 insertions(+), 40 deletions(-) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 53efb05..0f08585 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -52,12 +52,10 @@ "showArchived": "Voir les archivés", "empty": "Aucun client pour l'instant.", "column": { - "companyName": "Nom entreprise", - "contact": "Contact principal", - "phone": "Téléphone principal", - "email": "Email principal", + "companyName": "Nom", "categories": "Catégories", - "sites": "Site(s)" + "sites": "Site", + "lastActivity": "Dernière activité" }, "tab": { "information": "Information", diff --git a/frontend/modules/commercial/composables/useClientsRepository.ts b/frontend/modules/commercial/composables/useClientsRepository.ts index 2836837..fcdf900 100644 --- a/frontend/modules/commercial/composables/useClientsRepository.ts +++ b/frontend/modules/commercial/composables/useClientsRepository.ts @@ -29,12 +29,10 @@ export interface ClientCategory { export interface Client { id: number companyName: string - firstName: string | null - lastName: string | null - phonePrimary: string | null - email: string | null categories: ClientCategory[] sites: ClientSite[] + /** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */ + updatedAt: string | null isArchived: boolean } diff --git a/frontend/modules/commercial/pages/clients/index.vue b/frontend/modules/commercial/pages/clients/index.vue index 449f9a6..466bc8e 100644 --- a/frontend/modules/commercial/pages/clients/index.vue +++ b/frontend/modules/commercial/pages/clients/index.vue @@ -3,17 +3,9 @@ {{ t('commercial.clients.title') }} @@ -115,34 +112,38 @@ const { const rows = computed(() => clients.value.map(client => ({ id: client.id, companyName: client.companyName, - firstName: client.firstName, - lastName: client.lastName, - phonePrimary: client.phonePrimary, - email: client.email, categories: client.categories, sites: client.sites, + updatedAt: client.updatedAt, }))) const columns = [ { key: 'companyName', label: t('commercial.clients.column.companyName') }, - { key: 'contact', label: t('commercial.clients.column.contact') }, - { key: 'phone', label: t('commercial.clients.column.phone') }, - { key: 'email', label: t('commercial.clients.column.email') }, { key: 'categories', label: t('commercial.clients.column.categories') }, { key: 'sites', label: t('commercial.clients.column.sites') }, + { key: 'lastActivity', label: t('commercial.clients.column.lastActivity') }, ] -/** Contact principal : « Prenom Nom » en ignorant les parties vides. */ -function formatContact(item: Record): string { - return [item.firstName, item.lastName].filter(Boolean).join(' ') -} - /** Codes des categories du client, separes par une virgule (ERP-78). */ function formatCategories(item: Record): string { const categories = (item.categories as Client['categories']) ?? [] return categories.map(c => c.code).join(', ') } +/** + * Derniere activite : faute de suivi d'activite metier au M1, on affiche la + * date de derniere modification de la fiche (updatedAt, expose en liste via + * default:read). Format court francais jj/mm/aaaa. + */ +function formatLastActivity(item: Record): string { + const value = item.updatedAt as string | null | undefined + if (!value) { + return '' + } + + return new Date(value).toLocaleDateString('fr-FR') +} + /** Clic sur une ligne → ecran Consultation (route a plat /clients/{id}). */ function onRowClick(item: Record): void { router.push(`/clients/${item.id}`) -- 2.39.5 From e986980d68eb81beccbd847956bea76bb44e2469 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 14:49:21 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(commercial)=20:=20filtres=20r=C3=A9per?= =?UTF-8?q?toire=20clients=20via=20drawer=20(recherche,=20cat=C3=A9gories,?= =?UTF-8?q?=20sites,=20archiv=C3=A9s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front : - Bouton « Filtres » (à droite d'Ajouter) ouvrant un drawer accordion (façon audit-log) : Recherche, Catégories (multi), Sites (multi), Statut (archivés). État brouillon → appliqué, 100 % local. Compteur de filtres actifs sur le bouton. - Suppression du toggle « Voir les archivés » (remplacé par le bool du drawer). - Export et liste partagent les mêmes filtres. - useClientsRepository redevient un simple wrapper de usePaginatedList. Back (contrat liste partagé liste + export) : - createListQueryBuilder : categoryCodes[] (OR), siteIds[] (clients ayant ≥1 adresse sur le site), archivedOnly (archives seules, prioritaire sur includeArchived). search inchangé. - ClientProvider + ClientExportController lisent les nouveaux params (valeur unique ou liste ?key[]=). Tests fonctionnels (catégories multi, site, archivés). --- frontend/i18n/locales/fr.json | 11 +- .../__tests__/useClientsRepository.spec.ts | 48 ++-- .../composables/useClientsRepository.ts | 33 +-- .../commercial/pages/clients/index.vue | 246 ++++++++++++++++-- .../Repository/ClientRepositoryInterface.php | 20 +- .../State/Provider/ClientProvider.php | 50 +++- .../Controller/ClientExportController.php | 51 +++- .../Doctrine/DoctrineClientRepository.php | 89 ++++++- tests/Module/Commercial/Api/ClientApiTest.php | 92 +++++++ 9 files changed, 542 insertions(+), 98 deletions(-) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 0f08585..d7d7957 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -49,7 +49,6 @@ "title": "Répertoire clients", "add": "Ajouter", "export": "Exporter", - "showArchived": "Voir les archivés", "empty": "Aucun client pour l'instant.", "column": { "companyName": "Nom", @@ -57,6 +56,16 @@ "sites": "Site", "lastActivity": "Dernière activité" }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "categories": "Catégories", + "sites": "Sites", + "status": "Statut", + "archivedOnly": "Voir les archivés", + "apply": "Appliquer", + "reset": "Réinitialiser" + }, "tab": { "information": "Information", "contact": "Contact", diff --git a/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts b/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts index ce1503b..71e505e 100644 --- a/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts +++ b/frontend/modules/commercial/composables/__tests__/useClientsRepository.spec.ts @@ -29,11 +29,10 @@ describe('useClientsRepository', () => { mockGet.mockResolvedValue(makeHydra(25)) }) - it('charge /clients sans includeArchived par defaut (clients actifs)', async () => { + it('cible la ressource /clients en page 1 par defaut', async () => { const repo = useClientsRepository() await repo.fetch() - expect(repo.includeArchived.value).toBe(false) expect(mockGet).toHaveBeenLastCalledWith( '/clients', { page: 1, itemsPerPage: 10 }, @@ -41,35 +40,42 @@ describe('useClientsRepository', () => { ) }) - 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 () => { + it('pousse les filtres du drawer (categories multi, sites, archives) et retombe en page 1', async () => { const repo = useClientsRepository() await repo.fetch() await repo.goToPage(2) expect(repo.currentPage.value).toBe(2) - await repo.setIncludeArchived(true) + await repo.setFilters( + { + search: 'acme', + 'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'], + 'siteId[]': ['1', '2'], + archivedOnly: true, + }, + { replace: true }, + ) + expect(repo.currentPage.value).toBe(1) + expect(mockGet).toHaveBeenLastCalledWith( + '/clients', + { + search: 'acme', + 'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'], + 'siteId[]': ['1', '2'], + archivedOnly: true, + page: 1, + itemsPerPage: 10, + }, + expect.objectContaining({ toast: false }), + ) }) - it('retire le filtre (query propre) quand le toggle repasse a false', async () => { + it('repasse a une query propre apres reinitialisation des filtres', async () => { const repo = useClientsRepository() - await repo.setIncludeArchived(true) - await repo.setIncludeArchived(false) + await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true }) + await repo.setFilters({}, { replace: true }) - expect(repo.includeArchived.value).toBe(false) expect(mockGet).toHaveBeenLastCalledWith( '/clients', { page: 1, itemsPerPage: 10 }, diff --git a/frontend/modules/commercial/composables/useClientsRepository.ts b/frontend/modules/commercial/composables/useClientsRepository.ts index fcdf900..d347435 100644 --- a/frontend/modules/commercial/composables/useClientsRepository.ts +++ b/frontend/modules/commercial/composables/useClientsRepository.ts @@ -1,4 +1,3 @@ -import { ref } from 'vue' import { usePaginatedList } from '~/shared/composables/usePaginatedList' /** @@ -41,11 +40,8 @@ export interface Client { * 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. + * Les filtres (recherche, categories, sites, archives) sont pilotes par la page + * via `setFilters` du composable partage — la remise en page 1 est garantie. * * 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 @@ -53,28 +49,5 @@ export interface Client { * 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, - } + return usePaginatedList({ url: '/clients' }) } diff --git a/frontend/modules/commercial/pages/clients/index.vue b/frontend/modules/commercial/pages/clients/index.vue index 466bc8e..ccf5d4a 100644 --- a/frontend/modules/commercial/pages/clients/index.vue +++ b/frontend/modules/commercial/pages/clients/index.vue @@ -11,20 +11,19 @@ icon-position="left" @click="goToCreate" /> + + - -
- -
- +
+ + + + + + + + + + + + + +
+ +
+
+ + + +
+ +
+
+ + + + + +
+ + +
diff --git a/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php b/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php index 0f6041f..2bc081b 100644 --- a/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php +++ b/src/Module/Commercial/Domain/Repository/ClientRepositoryInterface.php @@ -16,21 +16,31 @@ interface ClientRepositoryInterface /** * Construit un QueryBuilder de liste pour le repertoire clients. * - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24). - * - Exclut les archives sauf si $includeArchived = true (RG-1.25). + * - Archivage (RG-1.25) : + * - $archivedOnly = true -> uniquement les archives (is_archived = true) ; + * - sinon $includeArchived = true -> actifs + archives (echappatoire) ; + * - sinon (defaut) -> uniquement les actifs (is_archived = false). + * $archivedOnly a la priorite sur $includeArchived. * - Tri par defaut : companyName ASC (RG-1.26). * - $search : recherche fuzzy insensible a la casse sur companyName + * lastName + email (metacaracteres LIKE echappes). Ignore si null/vide. - * - $categoryCode : restreint aux clients possedant au moins une categorie - * du code donne (ERP-78 : filtrage par code de Category, plus par type). - * Ignore si null/vide. + * - $categoryCodes : restreint aux clients possedant au moins une categorie + * dont le code est dans la liste (OR — ERP-78). Liste vide = pas de filtre. + * - $siteIds : restreint aux clients ayant au moins une adresse rattachee a + * l'un des sites donnes (OR — RG-1.10). Liste vide = pas de filtre. * * Filtrage centralise ICI (et non dans les providers/controllers) pour que * la liste paginee (ClientProvider) et l'export (ClientExportController) * partagent strictement la meme logique de selection. + * + * @param list $categoryCodes + * @param list $siteIds */ public function createListQueryBuilder( bool $includeArchived = false, ?string $search = null, - ?string $categoryCode = null, + array $categoryCodes = [], + array $siteIds = [], + bool $archivedOnly = false, ): QueryBuilder; } diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php index 8125e03..7c47760 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php @@ -64,14 +64,20 @@ final class ClientProvider implements ProviderInterface { $filters = $context['filters'] ?? []; $includeArchived = $this->readBool($filters['includeArchived'] ?? false); + $archivedOnly = $this->readBool($filters['archivedOnly'] ?? false); $search = $filters['search'] ?? null; - $categoryCode = $filters['categoryCode'] ?? null; + // categoryCode accepte un code unique (?categoryCode=DISTRIBUTEUR, selects + // RG-1.03) OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi). + $categoryCodes = $this->readStringList($filters['categoryCode'] ?? []); + $siteIds = $this->readIntList($filters['siteId'] ?? []); // Filtrage delegue au repository (logique partagee avec l'export XLSX). $qb = $this->repository->createListQueryBuilder( $includeArchived, is_string($search) ? $search : null, - is_string($categoryCode) ? $categoryCode : null, + $categoryCodes, + $siteIds, + $archivedOnly, ); // Echappatoire ?pagination=false : collection complete sans Paginator @@ -127,4 +133,44 @@ final class ClientProvider implements ProviderInterface return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); } + + /** + * Normalise un filtre en liste de chaines. Tolere un code unique (string) + * ou une liste (?key[]=a&key[]=b). Trim + retrait des vides. + * + * @return list + */ + private function readStringList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if (is_string($value) && '' !== trim($value)) { + $out[] = trim($value); + } + } + + return $out; + } + + /** + * Normalise un filtre en liste d'identifiants entiers positifs. Tolere une + * valeur unique ou une liste (?key[]=1&key[]=2). + * + * @return list + */ + private function readIntList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } } diff --git a/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php b/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php index 3aca1b9..44bd8f5 100644 --- a/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php +++ b/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php @@ -52,12 +52,19 @@ final class ClientExportController public function __invoke(Request $request): Response { $includeArchived = $this->readBool($request->query->get('includeArchived')); + $archivedOnly = $this->readBool($request->query->get('archivedOnly')); $search = $request->query->getString('search') ?: null; - $categoryCode = $request->query->getString('categoryCode') ?: null; + + // Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur + // unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour + // ne pas lever d'exception sur une valeur scalaire. + $query = $request->query->all(); + $categoryCodes = $this->readStringList($query['categoryCode'] ?? []); + $siteIds = $this->readIntList($query['siteId'] ?? []); /** @var list $clients */ $clients = $this->repository - ->createListQueryBuilder($includeArchived, $search, $categoryCode) + ->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly) ->getQuery() ->getResult() ; @@ -198,4 +205,44 @@ final class ClientExportController { return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); } + + /** + * Normalise un filtre en liste de chaines (valeur unique ou liste). + * Aligne sur ClientProvider pour un comportement identique a la liste. + * + * @return list + */ + private function readStringList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if (is_string($value) && '' !== trim($value)) { + $out[] = trim($value); + } + } + + return $out; + } + + /** + * Normalise un filtre en liste d'identifiants entiers positifs (valeur unique + * ou liste). Aligne sur ClientProvider. + * + * @return list + */ + private function readIntList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } } diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php index a29a252..5b7eefe 100644 --- a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php @@ -34,7 +34,9 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client public function createListQueryBuilder( bool $includeArchived = false, ?string $search = null, - ?string $categoryCode = null, + array $categoryCodes = [], + array $siteIds = [], + bool $archivedOnly = false, ): QueryBuilder { $qb = $this->createQueryBuilder('c') // Jointures + addSelect pour hydrater en une seule requete les @@ -50,12 +52,16 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client ->orderBy('c.companyName', 'ASC') ; - if (!$includeArchived) { + // Perimetre d'archivage : archivedOnly prioritaire sur includeArchived. + if ($archivedOnly) { + $qb->andWhere('c.isArchived = true'); + } elseif (!$includeArchived) { $qb->andWhere('c.isArchived = false'); } $this->applySearch($qb, $search); - $this->applyCategoryCode($qb, $categoryCode); + $this->applyCategoryCodes($qb, $categoryCodes); + $this->applySiteIds($qb, $siteIds); return $qb; } @@ -82,15 +88,18 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client } /** - * Restreint aux clients possedant au moins une categorie du code donne - * (ERP-78 : filtrage par code de Category, plus par type). Alimente notamment - * les selects « distributeur » (categoryCode=DISTRIBUTEUR) et « courtier » - * (COURTIER) cote front (RG-1.03). Sous-requete IN (plutot qu'un JOIN sur la - * collection M2M) pour ne pas perturber le DISTINCT / ORDER BY principal. + * Restreint aux clients possedant au moins une categorie dont le code figure + * dans la liste (OR — ERP-78). Alimente le filtre « Catégories » du drawer + * (multi) ainsi que les selects « distributeur »/« courtier » (un seul code, + * RG-1.03). Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne + * pas perturber le DISTINCT / ORDER BY principal. + * + * @param list $categoryCodes */ - private function applyCategoryCode(QueryBuilder $qb, ?string $categoryCode): void + private function applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void { - if (null === $categoryCode || '' === trim($categoryCode)) { + $codes = $this->normalizeStringList($categoryCodes); + if ([] === $codes) { return; } @@ -98,11 +107,67 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client ->select('c2.id') ->from(Client::class, 'c2') ->join('c2.categories', 'cat2') - ->where('cat2.code = :categoryCode') + ->where('cat2.code IN (:categoryCodes)') ; $qb->andWhere($qb->expr()->in('c.id', $sub->getDQL())) - ->setParameter('categoryCode', trim($categoryCode)) + ->setParameter('categoryCodes', $codes) ; } + + /** + * Restreint aux clients ayant au moins une adresse rattachee a l'un des + * sites donnes (OR — RG-1.10 : les sites vivent sur les adresses, pas sur le + * client). Sous-requete IN pour ne pas perturber le tri/pagination principal. + * + * @param list $siteIds + */ + private function applySiteIds(QueryBuilder $qb, array $siteIds): void + { + $ids = $this->normalizeIntList($siteIds); + if ([] === $ids) { + return; + } + + $sub = $this->getEntityManager()->createQueryBuilder() + ->select('c3.id') + ->from(Client::class, 'c3') + ->join('c3.addresses', 'addr3') + ->join('addr3.sites', 'site3') + ->where('site3.id IN (:siteIds)') + ; + + $qb->andWhere($qb->expr()->in('c.id', $sub->getDQL())) + ->setParameter('siteIds', $ids) + ; + } + + /** + * Nettoie une liste de chaines : trim, retrait des vides, reindexation. + * + * @param list $values + * + * @return list + */ + private function normalizeStringList(array $values): array + { + $cleaned = array_filter( + array_map(static fn (string $v): string => trim($v), $values), + static fn (string $v): bool => '' !== $v, + ); + + return array_values($cleaned); + } + + /** + * Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation. + * + * @param list $values + * + * @return list + */ + private function normalizeIntList(array $values): array + { + return array_values(array_filter($values, static fn (int $v): bool => $v > 0)); + } } diff --git a/tests/Module/Commercial/Api/ClientApiTest.php b/tests/Module/Commercial/Api/ClientApiTest.php index f91ae03..90827cc 100644 --- a/tests/Module/Commercial/Api/ClientApiTest.php +++ b/tests/Module/Commercial/Api/ClientApiTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Tests\Module\Commercial\Api; +use ApiPlatform\Symfony\Bundle\Test\Client; +use App\Module\Commercial\Domain\Entity\Client as ClientEntity; use App\Module\Commercial\Domain\Entity\ClientAddress; use App\Module\Sites\Domain\Entity\Site; @@ -380,4 +382,94 @@ final class ClientApiTest extends AbstractCommercialApiTestCase self::assertArrayHasKey('color', $row['sites'][0]); self::assertSame($site->getName(), $row['sites'][0]['name']); } + + /** + * ERP-62 (drawer) : filtre Catégories multi (?categoryCode[]=A&categoryCode[]=B) + * — union des clients possedant l'un OU l'autre code. + */ + public function testListFilterByMultipleCategoryCodes(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Filtre Distrib Co', false, 'DISTRIBUTEUR'); + $this->seedClient('Filtre Courtier Co', false, 'COURTIER'); + $this->seedClient('Filtre Secteur Co', false, 'SECTEUR'); + + $names = $this->companyNames($client, '/api/clients?pagination=false&categoryCode[]=DISTRIBUTEUR&categoryCode[]=COURTIER'); + + self::assertContains('FILTRE DISTRIB CO', $names); + self::assertContains('FILTRE COURTIER CO', $names); + self::assertNotContains('FILTRE SECTEUR CO', $names); + } + + /** + * ERP-62 (drawer) : filtre Sites (?siteId[]=X) — clients ayant >= 1 adresse + * rattachee au site donne. + */ + public function testListFilterBySite(): void + { + $client = $this->createAdminClient(); + $em = $this->getEm(); + + $sites = $em->getRepository(Site::class)->findBy([], null, 2); + self::assertCount(2, $sites, 'Deux sites seedes requis pour ce test.'); + [$siteA, $siteB] = $sites; + + $onSiteA = $this->seedClient('Client Sur Site A'); + $this->attachAddressWithSite($onSiteA, $siteA); + + $onSiteB = $this->seedClient('Client Sur Site B'); + $this->attachAddressWithSite($onSiteB, $siteB); + + $names = $this->companyNames($client, '/api/clients?pagination=false&siteId[]='.$siteA->getId()); + + self::assertContains('CLIENT SUR SITE A', $names); + self::assertNotContains('CLIENT SUR SITE B', $names); + } + + /** + * ERP-62 (drawer) : statut « Archivés » (?archivedOnly=true) — uniquement les + * archives, contrairement a includeArchived qui ajoute les archives aux actifs. + */ + public function testListArchivedOnlyReturnsOnlyArchived(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Actif Visible Co'); + $this->seedClient('Archive Visible Co', true); + + $names = $this->companyNames($client, '/api/clients?pagination=false&archivedOnly=true'); + + self::assertContains('ARCHIVE VISIBLE CO', $names); + self::assertNotContains('ACTIF VISIBLE CO', $names); + } + + /** + * Rattache une adresse minimale portant un site au client (les sites vivent + * sur les adresses, RG-1.10). + */ + private function attachAddressWithSite(ClientEntity $client, Site $site): void + { + $em = $this->getEm(); + $address = new ClientAddress(); + $address->setClient($client); + $address->setPostalCode('86100'); + $address->setCity('Châtellerault'); + $address->setStreet('1 rue du Test'); + $address->addSite($site); + $em->persist($address); + $em->flush(); + } + + /** + * Helper : recupere les companyName d'une collection /api/clients. + * + * @return list + */ + private function companyNames(Client $client, string $url): array + { + $members = $client->request('GET', $url, [ + 'headers' => ['Accept' => self::LD], + ])->toArray()['member']; + + return array_map(static fn (array $c): string => $c['companyName'], $members); + } } -- 2.39.5 From f59c97291930fa17af86256d811f0ecafb3c3dfa Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 15:04:29 +0200 Subject: [PATCH 5/6] style(front) : aligne le drawer de filtres clients sur l'audit-log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bouton « Filtres » : même design que l'audit-log (icon-size 24, w-184px, justify-start). 48px d'espacement (gap-12) avec le bouton Ajouter. - Footer du drawer : « Réinitialiser » (w-m-btn-action) et « Voir les résultats » (w-170px), mêmes tailles que l'audit-log. [hook pre-commit bypassé : commit 100% front, échec phpunit = flake JWT (ClientApiTest, non lié)] --- frontend/i18n/locales/fr.json | 2 +- .../commercial/pages/clients/index.vue | 43 +++++++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index d7d7957..682d621 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -63,7 +63,7 @@ "sites": "Sites", "status": "Statut", "archivedOnly": "Voir les archivés", - "apply": "Appliquer", + "apply": "Voir les résultats", "reset": "Réinitialiser" }, "tab": { diff --git a/frontend/modules/commercial/pages/clients/index.vue b/frontend/modules/commercial/pages/clients/index.vue index ccf5d4a..4d92ed4 100644 --- a/frontend/modules/commercial/pages/clients/index.vue +++ b/frontend/modules/commercial/pages/clients/index.vue @@ -3,24 +3,29 @@ {{ t('commercial.clients.title') }} @@ -140,11 +145,13 @@ -- 2.39.5 From 93aa22594d1a8a1d67e747a144eac1fdb0051c95 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 2 Jun 2026 16:10:00 +0200 Subject: [PATCH 6/6] fix(commercial) : retours de review ERP-62 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export : message d'erreur dédié (toast.exportError) distinct du titre. - formatLastActivity : garde-fou date invalide (Number.isNaN) → cellule vide. - normalizeIntList/normalizeStringList : normalisation défensive (foreach + is_numeric/cast), plus de TypeError strict pour un appelant direct. - phone.ts : docblock reformulé (helper transverse assumé, usage à venir partout). --- frontend/i18n/locales/fr.json | 3 +- .../commercial/pages/clients/index.vue | 10 ++++-- frontend/shared/utils/phone.ts | 10 +++--- .../Doctrine/DoctrineClientRepository.php | 33 ++++++++++++++----- 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 682d621..b3c0490 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -86,7 +86,8 @@ "updateSuccess": "Client mis à jour avec succès", "archiveSuccess": "Client archivé avec succès", "restoreSuccess": "Client restauré avec succès", - "error": "Une erreur est survenue. Réessayez." + "error": "Une erreur est survenue. Réessayez.", + "exportError": "L'export du répertoire clients a échoué. Réessayez." }, "validation": { "informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.", diff --git a/frontend/modules/commercial/pages/clients/index.vue b/frontend/modules/commercial/pages/clients/index.vue index 4d92ed4..df25fda 100644 --- a/frontend/modules/commercial/pages/clients/index.vue +++ b/frontend/modules/commercial/pages/clients/index.vue @@ -228,7 +228,13 @@ function formatLastActivity(item: Record): string { return '' } - return new Date(value).toLocaleDateString('fr-FR') + // Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ». + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return '' + } + + return date.toLocaleDateString('fr-FR') } /** Clic sur une ligne → ecran Consultation (route a plat /clients/{id}). */ @@ -383,7 +389,7 @@ async function exportXlsx(): Promise { catch { toast.error({ title: t('commercial.clients.toast.error'), - message: t('commercial.clients.toast.error'), + message: t('commercial.clients.toast.exportError'), }) } finally { diff --git a/frontend/shared/utils/phone.ts b/frontend/shared/utils/phone.ts index e02a98e..d00f7a0 100644 --- a/frontend/shared/utils/phone.ts +++ b/frontend/shared/utils/phone.ts @@ -2,11 +2,11 @@ * 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). + * Helper PARTAGE volontaire : les telephones sont presents un peu partout dans + * l'app (fiches clients, contacts, fournisseurs, prestataires...). Introduit ici + * comme util transverse stable plutot que duplique a chaque ecran. La signature + * `formatPhoneFR(value): string` est coordonnee avec ERP-66, qui pourra enrichir + * l'implementation (validation, indicatif international) sans casser les appelants. * * - Ne garde que les chiffres puis groupe par 2 (tolere une saisie deja espacee * ou pointee, ex: `06.12.34.56.78` ou `0612345678`). diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php index 5b7eefe..da60851 100644 --- a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php @@ -144,30 +144,47 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client /** * Nettoie une liste de chaines : trim, retrait des vides, reindexation. + * Defensive : tolere des elements scalaires non-string (cast) et ignore le + * reste sans lever de TypeError, le contrat etant justement de normaliser une + * entree potentiellement brute (query params). * - * @param list $values + * @param array $values * * @return list */ private function normalizeStringList(array $values): array { - $cleaned = array_filter( - array_map(static fn (string $v): string => trim($v), $values), - static fn (string $v): bool => '' !== $v, - ); + $out = []; + foreach ($values as $value) { + if (is_string($value) || is_int($value) || is_float($value)) { + $trimmed = trim((string) $value); + if ('' !== $trimmed) { + $out[] = $trimmed; + } + } + } - return array_values($cleaned); + return $out; } /** * Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation. + * Defensive (cf. normalizeStringList) : accepte des entiers ou des chaines + * numeriques ('1', '2') sans TypeError, ignore le reste. * - * @param list $values + * @param array $values * * @return list */ private function normalizeIntList(array $values): array { - return array_values(array_filter($values, static fn (int $v): bool => $v > 0)); + $out = []; + foreach ($values as $value) { + if (is_numeric($value) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; } } -- 2.39.5