diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json
index 8a2b0b0..911c67f 100644
--- a/frontend/i18n/locales/fr.json
+++ b/frontend/i18n/locales/fr.json
@@ -87,7 +87,24 @@
"archiveSuccess": "Client archivé avec succès",
"restoreSuccess": "Client restauré avec succès",
"error": "Une erreur est survenue. Réessayez.",
- "exportError": "L'export du répertoire clients a échoué. Réessayez."
+ "exportError": "L'export du répertoire clients a échoué. Réessayez.",
+ "restoreConflict": "Impossible de restaurer : un client actif portant ce nom existe déjà."
+ },
+ "consultation": {
+ "title": "Consultation client",
+ "back": "Retour au répertoire",
+ "loading": "Chargement du client…",
+ "notFound": "Client introuvable.",
+ "emptyContacts": "Aucun contact enregistré.",
+ "emptyAddresses": "Aucune adresse enregistrée.",
+ "confirmArchive": {
+ "title": "Archiver le client",
+ "message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
+ },
+ "confirmRestore": {
+ "title": "Restaurer le client",
+ "message": "Ce client réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
+ }
},
"validation": {
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue
index 53a93da..3539dbf 100644
--- a/frontend/modules/commercial/components/ClientAddressBlock.vue
+++ b/frontend/modules/commercial/components/ClientAddressBlock.vue
@@ -88,9 +88,11 @@
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). -->
-
+
props.modelValue)
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
const degraded = ref(false)
-const cityOptions = ref([])
+// Villes proposees par la BAN (alimentees a la saisie du code postal).
+const banCityOptions = ref([])
const addressOptions = ref([])
+
+// Options ville effectives : on garantit que la ville courante figure toujours
+// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options)
+// afficherait un champ vide en lecture seule (consultation 1.11) ou en edition
+// d'une adresse existante (1.12), ou la BAN n'a pas (re)peuple les suggestions.
+const cityOptions = computed(() => {
+ const current = props.modelValue.city
+ if (current && !banCityOptions.value.some(o => o.value === current)) {
+ return [{ value: current, label: current }, ...banCityOptions.value]
+ }
+ return banCityOptions.value
+})
const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
@@ -248,7 +263,7 @@ async function onPostalCodeChange(value: string): Promise {
}
try {
const suggestions = await autocomplete.searchCity(digits)
- cityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
+ banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
}
catch {
enterDegraded()
diff --git a/frontend/modules/commercial/composables/__tests__/useClient.spec.ts b/frontend/modules/commercial/composables/__tests__/useClient.spec.ts
new file mode 100644
index 0000000..f11b5f0
--- /dev/null
+++ b/frontend/modules/commercial/composables/__tests__/useClient.spec.ts
@@ -0,0 +1,95 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom).
+const mockGet = vi.hoisted(() => vi.fn())
+const mockPatch = vi.hoisted(() => vi.fn())
+
+vi.stubGlobal('useApi', () => ({
+ get: mockGet,
+ post: vi.fn(),
+ put: vi.fn(),
+ patch: mockPatch,
+ delete: vi.fn(),
+}))
+
+const { useClient } = await import('../useClient')
+
+const SAMPLE = { '@id': '/api/clients/42', id: 42, companyName: 'ACME', isArchived: false }
+
+describe('useClient', () => {
+ beforeEach(() => {
+ mockGet.mockReset()
+ mockPatch.mockReset()
+ mockGet.mockResolvedValue(SAMPLE)
+ mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
+ })
+
+ it('charge le detail via GET /clients/{id} en Hydra, sans toast', async () => {
+ const { client, load } = useClient(42)
+ await load()
+
+ expect(mockGet).toHaveBeenCalledWith(
+ '/clients/42',
+ {},
+ expect.objectContaining({
+ headers: { Accept: 'application/ld+json' },
+ toast: false,
+ }),
+ )
+ expect(client.value).toEqual(SAMPLE)
+ })
+
+ it('bascule loading pendant le chargement et le retombe a false', async () => {
+ const { loading, load } = useClient(42)
+ const promise = load()
+ expect(loading.value).toBe(true)
+ await promise
+ expect(loading.value).toBe(false)
+ })
+
+ it('marque error et laisse client null si le GET echoue (404...)', async () => {
+ mockGet.mockRejectedValueOnce(new Error('not found'))
+ const { client, error, load } = useClient(99)
+ await load()
+ expect(error.value).toBe(true)
+ expect(client.value).toBeNull()
+ })
+
+ it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => {
+ // 1er GET = chargement initial, 2e GET = rechargement post-archivage.
+ mockGet.mockResolvedValueOnce(SAMPLE)
+ mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true })
+ const { client, load, archive } = useClient(42)
+ await load()
+ await archive()
+
+ expect(mockPatch).toHaveBeenCalledWith(
+ '/clients/42',
+ { isArchived: true },
+ expect.objectContaining({ toast: false }),
+ )
+ // Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
+ expect(mockGet).toHaveBeenCalledTimes(2)
+ expect(client.value?.isArchived).toBe(true)
+ })
+
+ it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
+ const { load, restore } = useClient(42)
+ await load()
+ await restore()
+
+ expect(mockPatch).toHaveBeenCalledWith(
+ '/clients/42',
+ { isArchived: false },
+ expect.objectContaining({ toast: false }),
+ )
+ })
+
+ it('propage l\'erreur (ex: 409 conflit homonyme RG-1.23) au lieu de l\'avaler', async () => {
+ const conflict = { response: { status: 409 } }
+ mockPatch.mockRejectedValueOnce(conflict)
+ const { load, restore } = useClient(42)
+ await load()
+ await expect(restore()).rejects.toBe(conflict)
+ })
+})
diff --git a/frontend/modules/commercial/composables/useClient.ts b/frontend/modules/commercial/composables/useClient.ts
new file mode 100644
index 0000000..eff43cc
--- /dev/null
+++ b/frontend/modules/commercial/composables/useClient.ts
@@ -0,0 +1,70 @@
+import { ref } from 'vue'
+import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
+
+/**
+ * Chargement et actions d'archivage d'un client unique (ecran « Consultation
+ * client », 1.11). Lit le detail embarque via `GET /api/clients/{id}` (contacts /
+ * adresses / ribs sous `client:item:read` / `client:read:accounting`) et expose
+ * les bascules d'archivage (PATCH `isArchived` SEUL — tout autre champ => 422).
+ *
+ * L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
+ * Hydra complet (sans lui, API Platform 4 renvoie une representation reduite).
+ *
+ * Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs
+ * d'archivage/restauration (notamment le 409 RG-1.23 : homonyme actif a la
+ * restauration) sont PROPAGEES a l'appelant, qui decide du toast a afficher.
+ */
+export function useClient(id: number | string) {
+ const api = useApi()
+
+ const client = ref(null)
+ const loading = ref(false)
+ const error = ref(false)
+
+ /** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
+ function fetchDetail(): Promise {
+ return api.get(
+ `/clients/${id}`,
+ {},
+ { headers: { Accept: 'application/ld+json' }, toast: false },
+ )
+ }
+
+ /** Charge le detail du client. En cas d'echec : `error = true`, `client = null`. */
+ async function load(): Promise {
+ loading.value = true
+ error.value = false
+ try {
+ client.value = await fetchDetail()
+ }
+ catch {
+ error.value = true
+ client.value = null
+ }
+ finally {
+ loading.value = false
+ }
+ }
+
+ /**
+ * Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422),
+ * puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe
+ * `client:read` (ni l'embed contacts/adresses/ribs ni les libelles des
+ * referentiels comptables), un simple merge laisserait l'affichage incoherent.
+ * Toute erreur (notamment le 409 d'homonyme actif a la restauration, RG-1.23)
+ * est propagee a l'appelant AVANT le rechargement.
+ */
+ async function setArchived(isArchived: boolean): Promise {
+ await api.patch(`/clients/${id}`, { isArchived }, { toast: false })
+ client.value = await fetchDetail()
+ }
+
+ return {
+ client,
+ loading,
+ error,
+ load,
+ archive: () => setArchived(true),
+ restore: () => setArchived(false),
+ }
+}
diff --git a/frontend/modules/commercial/pages/clients/[id]/index.vue b/frontend/modules/commercial/pages/clients/[id]/index.vue
new file mode 100644
index 0000000..52ea3cd
--- /dev/null
+++ b/frontend/modules/commercial/pages/clients/[id]/index.vue
@@ -0,0 +1,481 @@
+
+
+
+
+
+
{{ headerTitle }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('commercial.clients.consultation.loading') }}
+
{{ t('commercial.clients.consultation.notFound') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('commercial.clients.consultation.emptyContacts') }}
+
+
+
+
+
+
+
+
+
+ {{ t('commercial.clients.consultation.emptyAddresses') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isArchived ? t('commercial.clients.consultation.confirmRestore.title') : t('commercial.clients.consultation.confirmArchive.title') }}
+
+
+ {{ isArchived ? t('commercial.clients.consultation.confirmRestore.message') : t('commercial.clients.consultation.confirmArchive.message') }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/modules/commercial/utils/__tests__/clientConsultation.spec.ts b/frontend/modules/commercial/utils/__tests__/clientConsultation.spec.ts
new file mode 100644
index 0000000..fbed776
--- /dev/null
+++ b/frontend/modules/commercial/utils/__tests__/clientConsultation.spec.ts
@@ -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)
+ })
+})
diff --git a/frontend/modules/commercial/utils/clientConsultation.ts b/frontend/modules/commercial/utils/clientConsultation.ts
new file mode 100644
index 0000000..a5b0ada
--- /dev/null
+++ b/frontend/modules/commercial/utils/clientConsultation.ts
@@ -0,0 +1,321 @@
+/**
+ * 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
+}