bc7c8f6f83
Auto Tag Develop / tag (push) Successful in 8s
## ERP-65 — Page Modification client (1.12)
Écran d'édition client à plat `/clients/[id]/edit`, pré-rempli depuis `GET /clients/{id}` (via `useClient`), édition **indépendante par onglet** avec PATCH **scopé au groupe de sérialisation dédié** (mode strict ERP-74).
### Périmètre
- **Bloc principal conservé** (décision produit) : éditable, PATCH `/clients/{id}` scopé `client:write:main`.
- Onglets **Information** / **Comptabilité** : PATCH `/clients/{id}` scopés à leur groupe ; **Contacts / Adresses / RIBs** via leurs sous-ressources (POST nouveau / PATCH existant / DELETE retiré).
- **Gating readonly par permission** : `manage` → bloc principal + Info/Contact/Adresse éditables ; Comptabilité visible ssi `accounting.view`, éditable ssi `accounting.manage`. Garde de route si ni `manage` ni `accounting.manage`.
- **Pas de miroir RG-1.04 côté front** (cohérent avec la création — le 422 serveur remonte au toast).
- **Chargement résilient des référentiels** (`loadCommon` → `Promise.allSettled`) + options en **union avec l'embed**, pour que les selects comptables de Compta se chargent malgré les 403 sur `/categories`+`/sites`, et que les valeurs courantes s'affichent toujours.
### Tests / vérifications
- Vitest : 22 nouveaux tests (`clientEdit.spec.ts` — scoping strict par groupe + gating par rôle + mappers) ; suite **180/180 OK**, aucune régression.
- ESLint propre.
- Golden path navigateur (Admin + Compta) : pré-remplissage, PATCH Information strictement scopé (corps = 7 champs information), gating readonly Compta, référentiels comptables chargés malgré 403 categories/sites, PATCH comptable Compta OK (200).
### À signaler (hors périmètre)
Les rôles métier (Bureau/Commerciale/Compta) n'ont pas `catalog.categories.view`/`sites.view` → 403 sur `/categories`/`/sites`. La page se dégrade proprement (valeurs courantes via embed) mais **ajouter une nouvelle catégorie/site** est impossible pour ces rôles (même limite que la création). Correctif = ticket RBAC backend (3 miroirs).
Reviewed-on: #51
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
/**
|
|
* 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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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,
|
|
}
|
|
}
|