/** * Helpers purs des écrans Consultation / Modification transporteur (M4, ERP-170) — * miroir de `providerDetail.ts` (M3). Mappent le payload `GET /api/carriers/{id}` * (relations embarquées via les groupes `carrier:item:read` + `qualimat:read` + * read-groups cross-module client/supplier/site/adresses) vers les brouillons * « plats » partagés avec les blocs Adresse / Contact / Prix. * * Ne touchent ni à l'API ni à l'état réactif (testables unitairement). Les champs * nuls peuvent être OMIS (skip_null_values) → toujours lire avec `?? null`. */ import { formatPhoneFR } from '~/shared/utils/phone' import type { CarrierAddressFormDraft, CarrierContactFormDraft, CarrierMainDraft, CarrierPriceFormDraft, } from '~/modules/transport/types/carrierForm' /** Référence Hydra embarquée minimale (@id toujours présent). */ export interface HydraRef { '@id': string [key: string]: unknown } /** Une relation peut être embarquée (objet), un IRI nu (chaîne) ou absente. */ export type Relation = HydraRef | string | null | undefined /** Adresse embarquée (groupe carrier:item:read). */ export interface CarrierAddressRead extends HydraRef { id: number country?: string | null postalCode?: string | null city?: string | null street?: string | null streetComplement?: string | null } /** Contact embarqué (groupe carrier:item:read). */ export interface CarrierContactRead extends HydraRef { id: number firstName?: string | null lastName?: string | null jobTitle?: string | null phonePrimary?: string | null phoneSecondary?: string | null email?: string | null } /** Prix embarqué (groupe carrier:item:read + relations cross-module). */ export interface CarrierPriceRead extends HydraRef { id: number direction?: string | null client?: Relation clientDeliveryAddress?: Relation departureSite?: Relation supplier?: Relation supplierSupplyAddress?: Relation deliverySite?: Relation containerType?: string | null pricingUnit?: string | null price?: string | null priceState?: string | null } /** * Détail d'un transporteur (`GET /api/carriers/{id}`). Tous les champs optionnels : * skip_null_values peut omettre n'importe quelle clé. */ export interface CarrierDetail extends HydraRef { id: number name?: string | null certificationType?: string | null isChartered?: boolean indexationRate?: string | null containerType?: string | null volumeM3?: string | null liotPlates?: string | null dischargeDocument?: Relation qualimatCarrier?: Relation isArchived?: boolean addresses?: CarrierAddressRead[] contacts?: CarrierContactRead[] prices?: CarrierPriceRead[] } /** Extrait l'IRI d'une relation (objet embarqué, 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 } /** * Libellé d'affichage d'une relation embarquée : `name` (site) à défaut une adresse * condensée (voie · CP · ville). Chaîne vide si la relation est un IRI nu / absente. */ export function labelOfRelation(relation: Relation): string { if (!relation || typeof relation === 'string') { return '' } const name = relation.name as string | undefined if (name) { return name } const parts = [relation.street, relation.postalCode, relation.city].filter(Boolean) return parts.join(' · ') } /** Mappe le détail vers le brouillon du formulaire principal. */ export function mapMainToDraft(detail: CarrierDetail): CarrierMainDraft { return { name: detail.name ?? '', certificationType: detail.certificationType ?? null, isChartered: detail.isChartered ?? false, indexationRate: detail.indexationRate ?? '', containerType: detail.containerType ?? null, volumeM3: detail.volumeM3 ?? '', liotPlates: detail.liotPlates ?? '', dischargeDocumentIri: iriOf(detail.dischargeDocument), qualimatCarrierIri: iriOf(detail.qualimatCarrier), } } /** Mappe une adresse embarquée vers un brouillon. */ export function mapAddressToDraft(address: CarrierAddressRead): CarrierAddressFormDraft { return { id: address.id, country: address.country ?? 'France', postalCode: address.postalCode ?? null, city: address.city ?? null, street: address.street ?? null, streetComplement: address.streetComplement ?? null, } } /** Mappe un contact embarqué vers un brouillon (téléphones formatés XX XX XX XX XX). */ export function mapContactToDraft(contact: CarrierContactRead): CarrierContactFormDraft { const secondary = contact.phoneSecondary ?? null return { id: contact.id, firstName: contact.firstName ?? null, lastName: contact.lastName ?? null, jobTitle: contact.jobTitle ?? null, phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null, phoneSecondary: secondary ? formatPhoneFR(secondary) : null, email: contact.email ?? null, hasSecondaryPhone: secondary !== null && secondary !== '', } } /** Mappe un prix embarqué vers un brouillon (relations en IRI). */ export function mapPriceToDraft(price: CarrierPriceRead): CarrierPriceFormDraft { const direction = price.direction === 'CLIENT' || price.direction === 'FOURNISSEUR' ? price.direction : null return { id: price.id, direction, clientIri: iriOf(price.client), clientDeliveryAddressIri: iriOf(price.clientDeliveryAddress), departureSiteIri: iriOf(price.departureSite), supplierIri: iriOf(price.supplier), supplierSupplyAddressIri: iriOf(price.supplierSupplyAddress), deliverySiteIri: iriOf(price.deliverySite), containerType: price.containerType ?? null, pricingUnit: price.pricingUnit ?? null, price: price.price ?? null, priceState: price.priceState ?? null, } } /** Bouton « Modifier » : visible avec la permission `manage` (Admin / Bureau). */ export function canEditCarrier(can: (code: string) => boolean): boolean { return can('transport.carriers.manage') } /** Bouton « Archiver » : permission archive ET transporteur encore actif (Admin seul). */ export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean { return can('transport.carriers.archive') && !isArchived } /** Bouton « Restaurer » : permission archive ET transporteur déjà archivé (Admin seul). */ export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean { return can('transport.carriers.archive') && isArchived }