[ERP-62] Page Répertoire clients (datatable + Ajouter / Exporter) (#44)
Auto Tag Develop / tag (push) Successful in 8s

## ERP-62 — Page Répertoire clients (datatable + Ajouter / Exporter)

Tâche Lesstime #480. **Stacke sur ERP-61** (clés i18n `commercial.clients.*`) — non encore mergé : la diff vers `develop` inclut le commit ERP-61 tant qu'il n'est pas mergé.

### Front
- Page `/clients` (route à plat) : `MalioDataTable` 6 colonnes (Nom entreprise / Contact / Téléphone formaté / Email / codes Catégories / badges Site(s)), toggle « Voir les archivés » (état 100 % local), boutons **+ Ajouter** (visible si `commercial.clients.manage`) et **Exporter** (visible si `view`, télécharge `clients/export.xlsx` via `useApi`), clic ligne → `/clients/{id}`, empty state.
- Composable `useClientsRepository` = wrapper de `usePaginatedList<Client>({ url: '/clients' })` + toggle `includeArchived` (repasse page 1).
- Util `formatPhoneFR` (signature cible à coordonner avec ERP-66 / 1.13) + clé i18n `showArchived`.

### Back — ⚠️ MAJ contrat de sérialisation (incluse dans cette MR)
Le `GET /api/clients` n'exposait ni les codes catégories ni les sites en liste (le bloc Lesstime l'affirmait à tort). Corrigé :
- `Client` : `category:read` + `site:read` ajoutés aux `normalizationContext` (GetCollection/Get/Post/Patch) + accesseur agrégé `getSites()` (`#[Groups(client:read)]`).
- `DoctrineClientRepository::createListQueryBuilder` : jointures + `addSelect` (categories / addresses / sites) anti N+1.
- Aucune migration (pure sérialisation).

### Tests
- Back : `ClientApiTest` (codes catégories + sites name/color en liste). `make test`  454.
- Front : `useClientsRepository.spec.ts` + `phone.test.ts`. `vitest`  111. `nuxi typecheck`  (mes fichiers).

### Non couvert
Golden path navigateur non joué : dev-nuxt (conteneur) cassé (résolution `@malio/layer-ui/tailwind.config.ts`) + BDD sans clients démo (nécessite `make db-reset`). Aspects front restants traités séparément.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #44
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #44.
This commit is contained in:
2026-06-02 14:16:29 +00:00
committed by Autin
parent 79dffccc79
commit ee1521384e
12 changed files with 1016 additions and 32 deletions
@@ -34,19 +34,34 @@ 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
// 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')
;
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;
}
@@ -73,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<string> $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;
}
@@ -89,11 +107,84 @@ 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<int> $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.
* 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 array<mixed> $values
*
* @return list<string>
*/
private function normalizeStringList(array $values): array
{
$out = [];
foreach ($values as $value) {
if (is_string($value) || is_int($value) || is_float($value)) {
$trimmed = trim((string) $value);
if ('' !== $trimmed) {
$out[] = $trimmed;
}
}
}
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 array<mixed> $values
*
* @return list<int>
*/
private function normalizeIntList(array $values): array
{
$out = [];
foreach ($values as $value) {
if (is_numeric($value) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}