feat(front) : page modification client + patch par onglet (ERP-65) (#51)
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>
This commit was merged in pull request #51.
This commit is contained in:
2026-06-03 10:51:33 +00:00
committed by Autin
parent 7833ff32e6
commit bc7c8f6f83
5 changed files with 1462 additions and 17 deletions
@@ -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<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,
}
}