/** * 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 } /** 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 }