[ERP-64] Page Consultation client (lecture seule + Modifier / Archiver) (#49)
Auto Tag Develop / tag (push) Successful in 9s

## ERP-64 — Page Consultation client (lecture seule)

Route **`/clients/[id]`** : consultation client en lecture seule, porte vers Modification + actions Archiver / Restaurer.

### Périmètre (front uniquement)
- **`useClient(id)`** : charge le détail (embed contacts / adresses / ribs), `archive()` / `restore()` via `PATCH { isArchived }` **seul**, puis **refetch complet** (la réponse du PATCH ne porte pas l'embed). Le **409** de conflit d'homonyme à la restauration (RG-1.23) est propagé → toast dédié.
- **Page** : formulaire principal + **8 onglets** readonly en **navigation libre** (4 actifs + 4 placeholders). Onglet **Comptabilité** visible **uniquement avec `accounting.view`**.
- **Boutons** : **Modifier** si `manage` OU `accounting.manage` ; **Archiver** si `archive` et client actif ; **Restaurer** si `archive` et client archivé.
- Téléphones affichés formatés `XX XX XX XX XX`.
- Réutilise `ClientContactBlock` / `ClientAddressBlock` / `TabPlaceholderBlank` (ERP-63) en mode `readonly`.

### Libellés issus de l'embed (role-independant)
`GET /api/categories` et `/api/sites` renvoient **403 pour les rôles métier non-admin**. La page lit donc tous les libellés (catégories, sites, référentiels comptables) **directement dans le payload embarqué** — affichage correct pour tous les rôles, sans dépendre d'un `GET` de référentiel.

### Correctifs `ClientAddressBlock` (lecture seule)
- la **ville** courante est toujours présente dans les options (sinon `MalioSelect` n'affiche rien) ;
- la **rue** s'affiche en champ texte readonly (`MalioInputAutocomplete` ne réaffiche pas sa valeur liée).

### Pas de changement back
L'embed `GET /api/clients/{id}` (contacts/adresses/ribs + sites + codes catégories, gating `accounting.view`, 409 restauration) **était déjà livré par ERP-62 (#44)** — vérifié sur l'API réelle et couvert par `ClientApiTest::testGetDetailEmbedsSubCollections`, `ClientReadGroupContextBuilderTest`, `ClientArchiveTest::testRestoreConflictReturns409`.

### Tests
- Vitest : **+29 tests** (mapping payload→brouillons, options embed, permissions, archive/restore/409). Suite complète **158 OK**.
- `nuxi typecheck` : 0 erreur sur les fichiers ajoutés.
- Golden path navigateur (admin + commerciale) : readonly complet, onglet Compta + RIBs selon `accounting.view`, boutons selon rôle, bascule Archiver ↔ Restaurer.

### ⚠️ À investiguer (hors périmètre)
Le 403 sur `/categories` et `/sites` impacte aussi `useClientReferentials.loadCommon()` (un `Promise.all` qui rejette en entier) → potentiellement le **formulaire de création ERP-63 cassé pour la Commerciale** (impossible de choisir catégories/sites). À confirmer dans un ticket dédié.

Reviewed-on: #49
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #49.
This commit is contained in:
2026-06-03 09:57:14 +00:00
committed by Autin
parent 276f242b10
commit 6fee9f6bd6
7 changed files with 1239 additions and 5 deletions
@@ -0,0 +1,235 @@
import { describe, expect, it } from 'vitest'
import {
canEditClient,
categoryOptionsOf,
contactOptionsOf,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
referentialOptionOf,
relationOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
type ClientDetail,
} from '../clientConsultation'
describe('iriOf', () => {
it('retourne l\'@id d\'une relation embarquee (objet)', () => {
expect(iriOf({ '@id': '/api/payment_types/10', code: 'LCR' })).toBe('/api/payment_types/10')
})
it('retourne la chaine telle quelle si la relation est deja un IRI', () => {
expect(iriOf('/api/banks/3')).toBe('/api/banks/3')
})
it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => {
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
})
describe('relationOf', () => {
it('detecte une relation distributeur et expose son nom', () => {
const client = { distributor: { '@id': '/api/clients/15', companyName: 'DISTRIB GRAND SUD-OUEST' } } as ClientDetail
expect(relationOf(client)).toEqual({ type: 'distributeur', name: 'DISTRIB GRAND SUD-OUEST' })
})
it('detecte une relation courtier et expose son nom', () => {
const client = { broker: { '@id': '/api/clients/16', companyName: 'CABINET LEONARD' } } as ClientDetail
expect(relationOf(client)).toEqual({ type: 'courtier', name: 'CABINET LEONARD' })
})
it('retourne type null quand aucune relation n\'est posee (cles omises)', () => {
expect(relationOf({} as ClientDetail)).toEqual({ type: null, name: null })
})
})
describe('mapContactToDraft', () => {
it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => {
const draft = mapContactToDraft({
'@id': '/api/client_contacts/18',
id: 18,
firstName: 'Sophie',
lastName: 'Léonard',
jobTitle: 'Gérante',
phonePrimary: '0549112233',
email: 'sophie@x.fr',
})
expect(draft.id).toBe(18)
expect(draft.iri).toBe('/api/client_contacts/18')
expect(draft.phonePrimary).toBe('05 49 11 22 33')
expect(draft.hasSecondaryPhone).toBe(false)
})
it('revele le 2e telephone quand phoneSecondary est present', () => {
const draft = mapContactToDraft({
'@id': '/api/client_contacts/19',
id: 19,
phonePrimary: '0600000000',
phoneSecondary: '0611111111',
})
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.phoneSecondary).toBe('06 11 11 11 11')
})
})
describe('mapAddressToDraft', () => {
it('extrait les iris de sites / categories / contacts (objets ou chaines)', () => {
const draft = mapAddressToDraft({
'@id': '/api/client_addresses/18',
id: 18,
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '5 rue des Courtiers',
billingEmail: 'factures@x.fr',
isProspect: false,
isDelivery: false,
isBilling: true,
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#056CF2' }],
categories: [{ '@id': '/api/categories/3', code: 'SECTEUR' }],
contacts: [{ '@id': '/api/client_contacts/18' }, '/api/client_contacts/20'],
})
expect(draft.siteIris).toEqual(['/api/sites/4'])
expect(draft.categoryIris).toEqual(['/api/categories/3'])
expect(draft.contactIris).toEqual(['/api/client_contacts/18', '/api/client_contacts/20'])
expect(draft.isBilling).toBe(true)
expect(draft.city).toBe('Châtellerault')
expect(draft.country).toBe('France')
})
it('tolere les sous-collections absentes (defaut tableau vide, pays France)', () => {
const draft = mapAddressToDraft({ '@id': '/api/client_addresses/9', id: 9 })
expect(draft.siteIris).toEqual([])
expect(draft.categoryIris).toEqual([])
expect(draft.contactIris).toEqual([])
expect(draft.country).toBe('France')
expect(draft.isBilling).toBe(false)
})
})
describe('mapRibToDraft', () => {
it('mappe label / bic / iban et l\'id serveur', () => {
const draft = mapRibToDraft({ '@id': '/api/client_ribs/3', id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
expect(draft).toEqual({ id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
})
})
describe('mapAccountingDraft', () => {
it('mappe les scalaires et resout les iris des referentiels embarques', () => {
const acc = mapAccountingDraft({
'@id': '/api/clients/1',
id: 1,
siren: '123456789',
accountNumber: '411000',
nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/1' },
paymentDelay: { '@id': '/api/payment_delays/2' },
paymentType: { '@id': '/api/payment_types/10', code: 'LCR' },
bank: { '@id': '/api/banks/3' },
} as ClientDetail)
expect(acc).toEqual({
siren: '123456789',
accountNumber: '411000',
nTva: 'FR123',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/2',
paymentTypeIri: '/api/payment_types/10',
bankIri: '/api/banks/3',
})
})
it('renvoie des null quand les champs comptables sont absents (sans accounting.view)', () => {
const acc = mapAccountingDraft({} as ClientDetail)
expect(acc).toEqual({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
bankIri: null,
})
})
})
describe('options construites depuis l\'embed (role-independantes)', () => {
it('categoryOptionsOf expose value=IRI, label=nom, code', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }])).toEqual([
{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' },
])
})
it('siteOptionsOf expose value=IRI, label=nom', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/4', label: 'Chatellerault' },
])
})
it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => {
expect(contactOptionsOf([
{ '@id': '/api/client_contacts/1', id: 1, firstName: 'Jean', lastName: 'Dupont' },
{ '@id': '/api/client_contacts/2', id: 2, email: 'a@b.fr' },
])).toEqual([
{ value: '/api/client_contacts/1', label: 'Jean Dupont' },
{ value: '/api/client_contacts/2', label: 'a@b.fr' },
])
})
it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => {
expect(referentialOptionOf({ '@id': '/api/payment_types/10', label: 'LCR' })).toEqual([
{ value: '/api/payment_types/10', label: 'LCR' },
])
expect(referentialOptionOf('/api/banks/3')).toEqual([])
expect(referentialOptionOf(null)).toEqual([])
})
it('mapAddressView assemble brouillon + options propres a l\'adresse', () => {
const view = mapAddressView({
'@id': '/api/client_addresses/18',
id: 18,
city: 'Châtellerault',
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault' }],
categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }],
})
expect(view.draft.id).toBe(18)
expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }])
})
})
describe('canEditClient', () => {
const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
it('visible pour manage', () => {
expect(canEditClient(can(['commercial.clients.manage']))).toBe(true)
})
it('visible pour accounting.manage (role Compta)', () => {
expect(canEditClient(can(['commercial.clients.accounting.manage']))).toBe(true)
})
it('masque sans aucune des deux permissions (role Usine)', () => {
expect(canEditClient(can(['commercial.clients.view']))).toBe(false)
})
})
describe('showArchiveAction / showRestoreAction', () => {
const can = (granted: string[]) => (code: string) => granted.includes(code)
it('Archiver : visible avec la permission archive ET client non archive', () => {
expect(showArchiveAction(can(['commercial.clients.archive']), false)).toBe(true)
expect(showArchiveAction(can(['commercial.clients.archive']), true)).toBe(false)
expect(showArchiveAction(can([]), false)).toBe(false)
})
it('Restaurer : visible avec la permission archive ET client archive', () => {
expect(showRestoreAction(can(['commercial.clients.archive']), true)).toBe(true)
expect(showRestoreAction(can(['commercial.clients.archive']), false)).toBe(false)
expect(showRestoreAction(can([]), true)).toBe(false)
})
})