diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 911c67f..4fa1c99 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -106,6 +106,15 @@ "message": "Ce client réapparaîtra dans le répertoire actif. Confirmer la restauration ?" } }, + "edit": { + "title": "Modifier le client", + "back": "Retour au répertoire", + "loading": "Chargement du client…", + "notFound": "Client introuvable.", + "emptyContacts": "Aucun contact enregistré.", + "emptyAddresses": "Aucune adresse enregistrée.", + "save": "Valider" + }, "validation": { "informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.", "contactRequired": "Au moins un contact (nom ou prénom) est obligatoire.", diff --git a/frontend/modules/commercial/composables/useClientReferentials.ts b/frontend/modules/commercial/composables/useClientReferentials.ts index 941b111..0f5bb8f 100644 --- a/frontend/modules/commercial/composables/useClientReferentials.ts +++ b/frontend/modules/commercial/composables/useClientReferentials.ts @@ -85,26 +85,32 @@ export function useClientReferentials() { /** * Charge en parallele les referentiels communs (hors distributeurs/courtiers, - * charges a la demande selon la relation choisie). Les selects compta ne sont - * pertinents que si l'utilisateur a acces a l'onglet, mais le cout est - * negligeable et simplifie l'orchestration. + * charges a la demande selon la relation choisie). + * + * Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole. + * Necessaire pour les roles metier qui n'ont pas toutes les permissions de + * lecture — ex. Compta a `commercial.clients.view` (donc /tva_modes, /banks... + * accessibles) mais PAS `catalog.categories.view` ni `sites.view` : sans + * isolation, le 403 sur /categories ferait echouer tout le bloc et viderait + * les selects comptables dont Compta a besoin sur l'ecran de modification. + * Un referentiel en echec reste simplement vide (l'ecran d'edition complete + * l'affichage des valeurs courantes depuis l'embed du detail client). */ async function loadCommon(): Promise { - const [cats, sitesList, tva, delays, types, banksList] = await Promise.all([ - fetchAll('/categories'), - fetchAll('/sites'), - fetchAll('/tva_modes'), - fetchAll('/payment_delays'), - fetchAll('/payment_types'), - fetchAll('/banks'), + await Promise.allSettled([ + fetchAll('/categories') + .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), + fetchAll('/sites') + .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }), + fetchAll('/tva_modes') + .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), + fetchAll('/payment_delays') + .then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }), + fetchAll('/payment_types') + .then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }), + fetchAll('/banks') + .then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }), ]) - - categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) - sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) - tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) - paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) - paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) - banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) } /** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */ diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue new file mode 100644 index 0000000..e1f64e5 --- /dev/null +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -0,0 +1,909 @@ + + + diff --git a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts new file mode 100644 index 0000000..5de9d8b --- /dev/null +++ b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest' +import { + buildAccountingPayload, + buildAddressPayload, + buildContactPayload, + buildInformationPayload, + buildMainPayload, + buildRibPayload, + mapAccountingFormDraft, + mapInformationDraft, + mapMainDraft, + resolveTabEditability, + type AccountingFormDraft, + type InformationFormDraft, + type MainFormDraft, +} from '../clientEdit' +import type { ClientDetail } from '../clientConsultation' +import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' + +// ── Fabriques de brouillons (valeurs distinctes pour reperer les fuites) ───── + +function mainDraft(overrides: Partial = {}): MainFormDraft { + return { + companyName: 'ACME', + firstName: 'Jean', + lastName: 'Dupont', + email: 'jean@acme.fr', + phonePrimary: '05 49 11 22 33', + phoneSecondary: null, + hasSecondaryPhone: false, + categoryIris: ['/api/categories/1'], + relationType: null, + distributorIri: null, + brokerIri: null, + triageService: false, + ...overrides, + } +} + +function informationDraft(overrides: Partial = {}): InformationFormDraft { + return { + description: 'desc', + competitors: 'concurrents', + foundedAt: '2010-05-01', + employeesCount: '42', + revenueAmount: '1000000', + profitAmount: '50000', + directorName: 'PDG', + ...overrides, + } +} + +function accountingDraft(overrides: Partial = {}): AccountingFormDraft { + return { + siren: '123456789', + accountNumber: 'C-001', + nTva: 'FR123', + tvaModeIri: '/api/tva_modes/1', + paymentDelayIri: '/api/payment_delays/1', + paymentTypeIri: '/api/payment_types/1', + bankIri: '/api/banks/1', + ...overrides, + } +} + +// Champs de chaque groupe de serialisation (miroir back ClientProcessor). +const MAIN_KEYS = [ + 'companyName', 'firstName', 'lastName', 'email', 'phonePrimary', + 'phoneSecondary', 'categories', 'distributor', 'broker', 'triageService', +] +const INFORMATION_KEYS = [ + 'description', 'competitors', 'foundedAt', 'employeesCount', + 'revenueAmount', 'profitAmount', 'directorName', +] +const ACCOUNTING_KEYS = ['siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', 'bank'] + +describe('buildMainPayload — scoping strict groupe client:write:main', () => { + it('n\'expose QUE les champs du groupe main (aucune fuite information/accounting)', () => { + expect(Object.keys(buildMainPayload(mainDraft())).sort()).toEqual([...MAIN_KEYS].sort()) + }) + + it('relation distributeur : renseigne distributor, force broker a null (RG-1.03)', () => { + const payload = buildMainPayload(mainDraft({ + relationType: 'distributeur', + distributorIri: '/api/clients/9', + brokerIri: '/api/clients/7', + })) + expect(payload.distributor).toBe('/api/clients/9') + expect(payload.broker).toBeNull() + }) + + it('relation courtier : renseigne broker, force distributor a null (RG-1.03)', () => { + const payload = buildMainPayload(mainDraft({ + relationType: 'courtier', + distributorIri: '/api/clients/9', + brokerIri: '/api/clients/7', + })) + expect(payload.broker).toBe('/api/clients/7') + expect(payload.distributor).toBeNull() + }) + + it('sans relation : distributor et broker a null', () => { + const payload = buildMainPayload(mainDraft({ relationType: null })) + expect(payload.distributor).toBeNull() + expect(payload.broker).toBeNull() + }) + + it('telephone secondaire non revele : envoie null meme si une valeur traine', () => { + const payload = buildMainPayload(mainDraft({ hasSecondaryPhone: false, phoneSecondary: '06 00 00 00 00' })) + expect(payload.phoneSecondary).toBeNull() + }) +}) + +describe('buildInformationPayload — scoping strict groupe client:write:information', () => { + it('n\'expose QUE les champs du groupe information (aucune fuite main/accounting)', () => { + expect(Object.keys(buildInformationPayload(informationDraft())).sort()).toEqual([...INFORMATION_KEYS].sort()) + }) + + it('convertit employeesCount en nombre et vide -> null', () => { + expect(buildInformationPayload(informationDraft({ employeesCount: '42' })).employeesCount).toBe(42) + expect(buildInformationPayload(informationDraft({ employeesCount: null })).employeesCount).toBeNull() + expect(buildInformationPayload(informationDraft({ employeesCount: '' })).employeesCount).toBeNull() + }) + + it('chaines vides normalisees en null', () => { + const payload = buildInformationPayload(informationDraft({ description: '', directorName: '' })) + expect(payload.description).toBeNull() + expect(payload.directorName).toBeNull() + }) +}) + +describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => { + it('n\'expose QUE les champs du groupe accounting (aucune fuite main/information)', () => { + expect(Object.keys(buildAccountingPayload(accountingDraft(), true)).sort()).toEqual([...ACCOUNTING_KEYS].sort()) + }) + + it('banque conservee si requise (Virement), forcee a null sinon (RG-1.12)', () => { + expect(buildAccountingPayload(accountingDraft(), true).bank).toBe('/api/banks/1') + expect(buildAccountingPayload(accountingDraft(), false).bank).toBeNull() + }) +}) + +describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => { + it('contact : telephone secondaire ignore si non revele', () => { + const contact: ContactFormDraft = { + id: 5, iri: '/api/client_contacts/5', firstName: 'A', lastName: 'B', + jobTitle: null, phonePrimary: '0549112233', phoneSecondary: '0600000000', + email: null, hasSecondaryPhone: false, + } + expect(buildContactPayload(contact).phoneSecondary).toBeNull() + }) + + it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => { + const address: AddressFormDraft = { + id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France', + postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null, + categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [], + billingEmail: 'facturation@acme.fr', + } + expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr') + expect(buildAddressPayload(address, false).billingEmail).toBeNull() + }) + + it('rib : label / bic / iban transmis tels quels', () => { + const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' } + expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }) + }) +}) + +describe('mapMainDraft — pre-remplissage bloc principal', () => { + it('formate les telephones, resout la relation et extrait les IRI', () => { + const client = { + '@id': '/api/clients/1', id: 1, + companyName: 'ACME', firstName: 'Jean', lastName: 'Dupont', email: 'jean@acme.fr', + phonePrimary: '0549112233', phoneSecondary: '0600000000', triageService: true, + categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }], + distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' }, + } as ClientDetail + + const draft = mapMainDraft(client) + expect(draft.phonePrimary).toBe('05 49 11 22 33') + expect(draft.phoneSecondary).toBe('06 00 00 00 00') + expect(draft.hasSecondaryPhone).toBe(true) + expect(draft.categoryIris).toEqual(['/api/categories/1']) + expect(draft.relationType).toBe('distributeur') + expect(draft.distributorIri).toBe('/api/clients/9') + expect(draft.brokerIri).toBeNull() + expect(draft.triageService).toBe(true) + }) + + it('gere les cles omises (skip_null_values) sans planter', () => { + const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail) + expect(draft.companyName).toBeNull() + expect(draft.hasSecondaryPhone).toBe(false) + expect(draft.categoryIris).toEqual([]) + expect(draft.relationType).toBeNull() + expect(draft.triageService).toBe(false) + }) +}) + +describe('mapInformationDraft — pre-remplissage onglet Information', () => { + it('tronque foundedAt en YYYY-MM-DD et stringifie employeesCount', () => { + const draft = mapInformationDraft({ + '@id': '/api/clients/1', id: 1, + foundedAt: '2010-05-01T00:00:00+00:00', employeesCount: 42, revenueAmount: '1000000', + } as ClientDetail) + expect(draft.foundedAt).toBe('2010-05-01') + expect(draft.employeesCount).toBe('42') + expect(draft.revenueAmount).toBe('1000000') + }) + + it('cles omises -> null', () => { + const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1 } as ClientDetail) + expect(draft.foundedAt).toBeNull() + expect(draft.employeesCount).toBeNull() + expect(draft.description).toBeNull() + }) +}) + +describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => { + it('extrait les scalaires et les IRI des referentiels embarques', () => { + const draft = mapAccountingFormDraft({ + '@id': '/api/clients/1', id: 1, + siren: '123456789', accountNumber: 'C-001', nTva: 'FR123', + tvaMode: { '@id': '/api/tva_modes/2', label: 'Normal' }, + paymentType: '/api/payment_types/3', + } as ClientDetail) + expect(draft.siren).toBe('123456789') + expect(draft.tvaModeIri).toBe('/api/tva_modes/2') + expect(draft.paymentTypeIri).toBe('/api/payment_types/3') + expect(draft.bankIri).toBeNull() + }) +}) + +describe('resolveTabEditability — gating par role (matrice § 2.7)', () => { + it('Admin : tout editable', () => { + expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true })) + .toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true }) + }) + + it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => { + expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false })) + .toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false }) + }) + + it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => { + expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true })) + .toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true }) + }) + + it('Sans permission d\'edition : rien d\'editable', () => { + expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false })) + .toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false }) + }) +}) diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts new file mode 100644 index 0000000..9031076 --- /dev/null +++ b/frontend/modules/commercial/utils/clientEdit.ts @@ -0,0 +1,266 @@ +/** + * Helpers purs de l'ecran « Modification client » (M1 Commercial, 1.12). + * + * Deux responsabilites, toutes deux testables unitairement (cf. clientEdit.spec.ts) : + * 1. Pre-remplissage : mapper le payload `GET /api/clients/{id}` (embed + * contacts/adresses/ribs + scalaires) vers les brouillons « plats » edites + * par la page et les blocs reutilisables (mappers contacts/adresses/ribs/ + * comptabilite reutilises depuis clientConsultation). + * 2. Scoping STRICT des payloads PATCH (mode strict RG-1.28 / ERP-74) : chaque + * onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un + * payload mixte — un champ hors-permission = 403 sur l'integralite cote back. + * + * Ces helpers ne touchent ni a l'API ni a l'etat reactif. + * + * NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON + * miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de + * role et Bureau partage les permissions de Commerciale. Le back l'applique de + * maniere fiable (422) ; on laisse remonter ce 422 en toast. + */ + +import { + iriOf, + relationOf, + type ClientDetail, +} from '~/modules/commercial/utils/clientConsultation' +import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' +import { formatPhoneFR } from '~/shared/utils/phone' + +/** + * Etat « plat » du bloc principal (groupe client:write:main). Distinct des + * brouillons Contact : ces champs vivent sur le Client lui-meme (companyName, + * contact principal, telephones, email, categories, relation, triage), pas sur + * une sous-ressource ClientContact. + */ +export interface MainFormDraft { + companyName: string | null + firstName: string | null + lastName: string | null + email: string | null + phonePrimary: string | null + phoneSecondary: string | null + /** UI : le 2e numero a ete revele (ou existait deja au chargement). */ + hasSecondaryPhone: boolean + /** IRI des categories rattachees (M2M). */ + categoryIris: string[] + relationType: 'distributeur' | 'courtier' | null + distributorIri: string | null + brokerIri: string | null + triageService: boolean +} + +/** Etat « plat » de l'onglet Information (groupe client:write:information). */ +export interface InformationFormDraft { + description: string | null + competitors: string | null + /** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */ + foundedAt: string | null + /** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */ + employeesCount: string | null + revenueAmount: string | null + profitAmount: string | null + directorName: string | null +} + +/** Etat « plat » de l'onglet Comptabilite (groupe client:write:accounting). */ +export interface AccountingFormDraft { + siren: string | null + accountNumber: string | null + nTva: string | null + tvaModeIri: string | null + paymentDelayIri: string | null + paymentTypeIri: string | null + bankIri: string | null +} + +/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un client. */ +export interface ClientEditAbilities { + /** `commercial.clients.manage` : bloc principal + onglets metier. */ + canManage: boolean + /** `commercial.clients.accounting.view` : visibilite de l'onglet Comptabilite. */ + canAccountingView: boolean + /** `commercial.clients.accounting.manage` : edition de l'onglet Comptabilite. */ + canAccountingManage: boolean +} + +/** Editabilite resolue par zone d'onglet (deduite des permissions). */ +export interface TabEditability { + /** Bloc principal + onglets Information / Contact / Adresse editables. */ + businessEditable: boolean + /** Onglet Comptabilite present (affiche). */ + accountingVisible: boolean + /** Onglet Comptabilite editable. */ + accountingEditable: boolean +} + +// ── Pre-remplissage (GET detail -> brouillons) ────────────────────────────── + +/** + * Mappe le detail client vers le brouillon du bloc principal. Les telephones + * sont reformates XX XX XX XX XX (RG d'affichage). La relation Distributeur/ + * Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait de l'embed. + */ +export function mapMainDraft(client: ClientDetail): MainFormDraft { + const relation = relationOf(client) + const phoneSecondary = client.phoneSecondary ?? null + + return { + companyName: client.companyName ?? null, + firstName: client.firstName ?? null, + lastName: client.lastName ?? null, + email: client.email ?? null, + phonePrimary: client.phonePrimary ? formatPhoneFR(client.phonePrimary) : null, + phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null, + hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '', + categoryIris: (client.categories ?? []).map(c => c['@id']), + relationType: relation.type, + distributorIri: iriOf(client.distributor), + brokerIri: iriOf(client.broker), + triageService: client.triageService === true, + } +} + +/** Mappe le detail client vers le brouillon de l'onglet Information. */ +export function mapInformationDraft(client: ClientDetail): InformationFormDraft { + return { + description: client.description ?? null, + competitors: client.competitors ?? null, + // MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime. + foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null, + employeesCount: client.employeesCount != null ? String(client.employeesCount) : null, + revenueAmount: client.revenueAmount ?? null, + profitAmount: client.profitAmount ?? null, + directorName: client.directorName ?? null, + } +} + +/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */ +export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraft { + 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), + } +} + +// ── Scoping strict des payloads PATCH ──────────────────────────────────────── + +/** + * Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation + * Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne + * que la FK correspondant au type choisi, l'autre est forcee a null. + */ +export function buildMainPayload(main: MainFormDraft): Record { + return { + companyName: main.companyName, + firstName: main.firstName || null, + lastName: main.lastName || null, + email: main.email, + phonePrimary: main.phonePrimary || null, + phoneSecondary: main.hasSecondaryPhone ? (main.phoneSecondary || null) : null, + categories: main.categoryIris, + distributor: main.relationType === 'distributeur' ? main.distributorIri : null, + broker: main.relationType === 'courtier' ? main.brokerIri : null, + triageService: main.triageService, + } +} + +/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */ +export function buildInformationPayload(information: InformationFormDraft): Record { + return { + description: information.description || null, + competitors: information.competitors || null, + foundedAt: information.foundedAt || null, + employeesCount: information.employeesCount ? Number(information.employeesCount) : null, + revenueAmount: information.revenueAmount || null, + profitAmount: information.profitAmount || null, + directorName: information.directorName || null, + } +} + +/** + * Payload des scalaires de l'onglet Comptabilite — groupe client:write:accounting + * UNIQUEMENT (les RIB passent par la sous-ressource /clients/{id}/ribs). La banque + * n'a de sens que pour un Virement (RG-1.12) : forcee a null sinon. + */ +export function buildAccountingPayload( + accounting: AccountingFormDraft, + isBankRequired: boolean, +): Record { + return { + siren: accounting.siren || null, + accountNumber: accounting.accountNumber || null, + tvaMode: accounting.tvaModeIri, + nTva: accounting.nTva || null, + paymentDelay: accounting.paymentDelayIri, + paymentType: accounting.paymentTypeIri, + bank: isBankRequired ? accounting.bankIri : null, + } +} + +/** Payload d'un contact (sous-ressource client_contact). */ +export function buildContactPayload(contact: ContactFormDraft): Record { + return { + firstName: contact.firstName || null, + lastName: contact.lastName || null, + jobTitle: contact.jobTitle || null, + phonePrimary: contact.phonePrimary || null, + phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null, + email: contact.email || null, + } +} + +/** Payload d'une adresse (sous-ressource client_address). */ +export function buildAddressPayload( + address: AddressFormDraft, + isBillingEmailRequired: boolean, +): Record { + return { + isProspect: address.isProspect, + isDelivery: address.isDelivery, + isBilling: address.isBilling, + country: address.country, + postalCode: address.postalCode || null, + city: address.city || null, + street: address.street || null, + streetComplement: address.streetComplement || null, + categories: address.categoryIris, + sites: address.siteIris, + contacts: address.contactIris, + billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null, + } +} + +/** Payload d'un RIB (sous-ressource client_rib). */ +export function buildRibPayload(rib: RibFormDraft): Record { + return { + label: rib.label, + bic: rib.bic, + iban: rib.iban, + } +} + +// ── Gating par permission ──────────────────────────────────────────────────── + +/** + * Resout l'editabilite par zone a partir des permissions (option 1 ERP-74, + * miroir UI du re-gating champ-par-champ du ClientProcessor) : + * - bloc principal + Information/Contact/Adresse : editables ssi `manage` ; + * - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`. + * + * Produit le comportement attendu : + * - Admin : tout editable. + * - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee. + * - Compta (accounting seul, sans manage) : metier readonly, Compta editable. + */ +export function resolveTabEditability(abilities: ClientEditAbilities): TabEditability { + return { + businessEditable: abilities.canManage, + accountingVisible: abilities.canAccountingView, + accountingEditable: abilities.canAccountingManage, + } +}