6fee9f6bd6
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>
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
/**
|
|
* Helpers purs de l'ecran « Consultation client » (M1 Commercial, lecture seule).
|
|
*
|
|
* Mappent le payload `GET /api/clients/{id}` (relations embarquees, cf. groupe
|
|
* `client:item:read` + `client:read:accounting`) vers les brouillons « plats »
|
|
* partages avec les blocs reutilisables `ClientContactBlock` / `ClientAddressBlock`
|
|
* et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables
|
|
* unitairement (cf. clientConsultation.spec.ts).
|
|
*
|
|
* Rappels de contrat back (verifies sur l'API reelle) :
|
|
* - les relations ManyToOne (distributor/broker/tvaMode/paymentType/...) sont
|
|
* serialisees en OBJETS embarques (avec @id + companyName/code/label), pas en IRI nu ;
|
|
* - les champs nuls sont OMIS du JSON (skip_null_values) → toujours lire avec `?? null` ;
|
|
* - les champs comptables et `ribs` sont TOTALEMENT ABSENTS sans permission
|
|
* accounting.view (gate serveur via ClientReadGroupContextBuilder).
|
|
*/
|
|
|
|
import { formatPhoneFR } from '~/shared/utils/phone'
|
|
import type {
|
|
AddressFormDraft,
|
|
ContactFormDraft,
|
|
RibFormDraft,
|
|
} from '~/modules/commercial/types/clientForm'
|
|
|
|
/** Reference Hydra embarquee minimale (@id toujours present). */
|
|
export interface HydraRef {
|
|
'@id': string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
|
|
export type Relation = HydraRef | string | null | undefined
|
|
|
|
/** Site embarque dans une adresse (groupe site:read). */
|
|
export interface SiteRead extends HydraRef {
|
|
name?: string
|
|
color?: string
|
|
}
|
|
|
|
/** Categorie embarquee (groupe category:read). */
|
|
export interface CategoryRead extends HydraRef {
|
|
code?: string
|
|
name?: string
|
|
}
|
|
|
|
/** Contact embarque (groupe client_contact:read). */
|
|
export interface ContactRead extends HydraRef {
|
|
id: number
|
|
firstName?: string | null
|
|
lastName?: string | null
|
|
jobTitle?: string | null
|
|
phonePrimary?: string | null
|
|
phoneSecondary?: string | null
|
|
email?: string | null
|
|
}
|
|
|
|
/** Adresse embarquee (groupe client_address:read). */
|
|
export interface AddressRead extends HydraRef {
|
|
id: number
|
|
country?: string | null
|
|
postalCode?: string | null
|
|
city?: string | null
|
|
street?: string | null
|
|
streetComplement?: string | null
|
|
billingEmail?: string | null
|
|
isProspect?: boolean
|
|
isDelivery?: boolean
|
|
isBilling?: boolean
|
|
sites?: SiteRead[]
|
|
categories?: CategoryRead[]
|
|
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
|
contacts?: Array<HydraRef | string>
|
|
}
|
|
|
|
/** RIB embarque (groupe client:read:accounting, present ssi accounting.view). */
|
|
export interface RibRead extends HydraRef {
|
|
id: number
|
|
label?: string | null
|
|
bic?: string | null
|
|
iban?: string | null
|
|
}
|
|
|
|
/** Client relie (distributeur / courtier) embarque (groupe client:read). */
|
|
export interface RelatedClientRead extends HydraRef {
|
|
companyName?: string | null
|
|
}
|
|
|
|
/**
|
|
* Detail d'un client tel que renvoye par `GET /api/clients/{id}`. Tous les
|
|
* champs sont optionnels : skip_null_values cote serveur et gating accounting
|
|
* peuvent omettre n'importe quelle cle.
|
|
*/
|
|
export interface ClientDetail extends HydraRef {
|
|
id: number
|
|
companyName?: string | null
|
|
firstName?: string | null
|
|
lastName?: string | null
|
|
phonePrimary?: string | null
|
|
phoneSecondary?: string | null
|
|
email?: string | null
|
|
triageService?: boolean
|
|
isArchived?: boolean
|
|
categories?: CategoryRead[]
|
|
distributor?: RelatedClientRead | string | null
|
|
broker?: RelatedClientRead | string | null
|
|
contacts?: ContactRead[]
|
|
addresses?: AddressRead[]
|
|
ribs?: RibRead[]
|
|
// Onglet Information
|
|
description?: string | null
|
|
competitors?: string | null
|
|
foundedAt?: string | null
|
|
employeesCount?: number | null
|
|
revenueAmount?: string | null
|
|
profitAmount?: string | null
|
|
directorName?: string | null
|
|
// Onglet Comptabilite (present ssi accounting.view)
|
|
siren?: string | null
|
|
accountNumber?: string | null
|
|
nTva?: string | null
|
|
tvaMode?: Relation
|
|
paymentDelay?: Relation
|
|
paymentType?: Relation
|
|
bank?: Relation
|
|
}
|
|
|
|
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire 1.10). */
|
|
export interface AccountingDraft {
|
|
siren: string | null
|
|
accountNumber: string | null
|
|
nTva: string | null
|
|
tvaModeIri: string | null
|
|
paymentDelayIri: string | null
|
|
paymentTypeIri: string | null
|
|
bankIri: string | null
|
|
}
|
|
|
|
/** Relation Distributeur/Courtier resolue pour l'affichage en lecture seule. */
|
|
export interface ClientRelation {
|
|
type: 'distributeur' | 'courtier' | null
|
|
name: string | null
|
|
}
|
|
|
|
/** Option de select ({ value, label }) construite a partir de l'embed. */
|
|
export interface SelectOption {
|
|
value: string
|
|
label: string
|
|
}
|
|
|
|
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
|
|
export interface CategorySelectOption extends SelectOption {
|
|
code: string
|
|
}
|
|
|
|
/**
|
|
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
|
|
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
|
|
*/
|
|
export interface AddressView {
|
|
draft: AddressFormDraft
|
|
siteOptions: SelectOption[]
|
|
categoryOptions: CategorySelectOption[]
|
|
}
|
|
|
|
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
|
|
export function iriOf(relation: Relation): string | null {
|
|
if (relation === null || relation === undefined) {
|
|
return null
|
|
}
|
|
if (typeof relation === 'string') {
|
|
return relation
|
|
}
|
|
return relation['@id'] ?? null
|
|
}
|
|
|
|
/**
|
|
* Resout la relation Distributeur/Courtier (RG-1.03 : mutuellement exclusives).
|
|
* Le nom est lu sur l'objet embarque (`companyName`) ; null si la relation est
|
|
* un IRI nu ou absente.
|
|
*/
|
|
export function relationOf(client: ClientDetail): ClientRelation {
|
|
const nameOf = (rel: RelatedClientRead | string | null | undefined): string | null =>
|
|
rel && typeof rel === 'object' ? (rel.companyName ?? null) : null
|
|
|
|
if (client.distributor) {
|
|
return { type: 'distributeur', name: nameOf(client.distributor) }
|
|
}
|
|
if (client.broker) {
|
|
return { type: 'courtier', name: nameOf(client.broker) }
|
|
}
|
|
return { type: null, name: null }
|
|
}
|
|
|
|
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
|
|
export function mapContactToDraft(contact: ContactRead): ContactFormDraft {
|
|
const phoneSecondary = contact.phoneSecondary ?? null
|
|
return {
|
|
id: contact.id,
|
|
iri: contact['@id'] ?? null,
|
|
firstName: contact.firstName ?? null,
|
|
lastName: contact.lastName ?? null,
|
|
jobTitle: contact.jobTitle ?? null,
|
|
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
|
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
|
|
email: contact.email ?? null,
|
|
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
|
|
}
|
|
}
|
|
|
|
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
|
|
export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
|
|
return {
|
|
id: address.id,
|
|
isProspect: address.isProspect ?? false,
|
|
isDelivery: address.isDelivery ?? false,
|
|
isBilling: address.isBilling ?? false,
|
|
country: address.country ?? 'France',
|
|
postalCode: address.postalCode ?? null,
|
|
city: address.city ?? null,
|
|
street: address.street ?? null,
|
|
streetComplement: address.streetComplement ?? null,
|
|
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
|
siteIris: (address.sites ?? []).map(s => s['@id']),
|
|
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
|
billingEmail: address.billingEmail ?? null,
|
|
}
|
|
}
|
|
|
|
/** Mappe un RIB embarque vers un brouillon. */
|
|
export function mapRibToDraft(rib: RibRead): RibFormDraft {
|
|
return {
|
|
id: rib.id,
|
|
label: rib.label ?? null,
|
|
bic: rib.bic ?? null,
|
|
iban: rib.iban ?? null,
|
|
}
|
|
}
|
|
|
|
/** Mappe les champs comptables du client (scalaires + IRI des referentiels). */
|
|
export function mapAccountingDraft(client: ClientDetail): AccountingDraft {
|
|
return {
|
|
siren: client.siren ?? null,
|
|
accountNumber: client.accountNumber ?? null,
|
|
nTva: client.nTva ?? null,
|
|
tvaModeIri: iriOf(client.tvaMode),
|
|
paymentDelayIri: iriOf(client.paymentDelay),
|
|
paymentTypeIri: iriOf(client.paymentType),
|
|
bankIri: iriOf(client.bank),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
|
|
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
|
|
* roles metier non-admin), qui laisserait les libelles vides.
|
|
*/
|
|
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
|
|
return (categories ?? []).map(c => ({
|
|
value: c['@id'],
|
|
label: c.name ?? c.code ?? c['@id'],
|
|
code: c.code ?? '',
|
|
}))
|
|
}
|
|
|
|
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
|
|
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
|
|
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
|
|
}
|
|
|
|
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed client. */
|
|
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
|
|
return (contacts ?? []).map(c => ({
|
|
value: c['@id'],
|
|
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
|
|
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
|
|
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
|
|
* `GET` de referentiel — l'affichage reste correct quel que soit le role.
|
|
*/
|
|
export function referentialOptionOf(relation: Relation): SelectOption[] {
|
|
if (!relation || typeof relation === 'string') {
|
|
return []
|
|
}
|
|
const label = (relation.label as string | undefined)
|
|
?? (relation.name as string | undefined)
|
|
?? relation['@id']
|
|
return [{ value: relation['@id'], label }]
|
|
}
|
|
|
|
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
|
export function mapAddressView(address: AddressRead): AddressView {
|
|
return {
|
|
draft: mapAddressToDraft(address),
|
|
siteOptions: siteOptionsOf(address.sites),
|
|
categoryOptions: categoryOptionsOf(address.categories),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
|
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
|
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
|
|
* par onglet est gere sur l'ecran d'edition (1.12).
|
|
*/
|
|
export function canEditClient(canAny: (codes: string[]) => boolean): boolean {
|
|
return canAny(['commercial.clients.manage', 'commercial.clients.accounting.manage'])
|
|
}
|
|
|
|
/** Bouton « Archiver » : permission archive ET client encore actif. */
|
|
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
|
return can('commercial.clients.archive') && !isArchived
|
|
}
|
|
|
|
/** Bouton « Restaurer » : permission archive ET client deja archive. */
|
|
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
|
return can('commercial.clients.archive') && isArchived
|
|
}
|