Files
Starseed/frontend/modules/commercial/composables/useClientReferentials.ts
T
tristan bc7c8f6f83
Auto Tag Develop / tag (push) Successful in 8s
feat(front) : page modification client + patch par onglet (ERP-65) (#51)
## 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>
2026-06-03 10:51:33 +00:00

148 lines
5.5 KiB
TypeScript

import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
* reglement, banques, et les listes distributeurs / courtiers.
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
* l'en-tete `Accept: application/ld+json` impose par API Platform 4 pour obtenir
* l'enveloppe Hydra (`member`). Les valeurs d'option sont les IRI Hydra (`@id`)
* pour pouvoir etre renvoyees telles quelles dans les payloads POST/PATCH
* (relations ManyToOne / ManyToMany).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox ({ label, value }). */
export interface RefOption {
value: string
label: string
}
/** Option de type de reglement enrichie de son code stable (RG-1.12 / RG-1.13). */
export interface PaymentTypeOption extends RefOption {
code: string
}
/** Option de categorie enrichie de son code stable (filtrage RG-1.29 cote adresse). */
export interface CategoryOption extends RefOption {
code: string
}
/** Option de client (distributeur / courtier) — value = IRI du client lie. */
export type ClientOption = RefOption
interface HydraMember {
'@id': string
}
interface CategoryMember extends HydraMember {
code: string
name: string
}
interface SiteMember extends HydraMember {
name: string
}
interface ReferentialMember extends HydraMember {
code: string
label: string
}
interface ClientMember extends HydraMember {
companyName: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useClientReferentials() {
const api = useApi()
const categories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
const distributors = ref<ClientOption[]>([])
const brokers = ref<ClientOption[]>([])
/** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>(
url: string,
query: Record<string, string | string[]> = {},
): Promise<T[]> {
const res = await api.get<{ member?: T[] }>(
url,
{ pagination: 'false', ...query },
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
/**
* Charge en parallele les referentiels communs (hors distributeurs/courtiers,
* 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<void> {
await Promise.allSettled([
fetchAll<CategoryMember>('/categories')
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites')
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }),
fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays')
.then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }),
fetchAll<ReferentialMember>('/payment_types')
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
fetchAll<ReferentialMember>('/banks')
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
])
}
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
async function loadDistributors(): Promise<void> {
if (distributors.value.length > 0) {
return
}
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'DISTRIBUTEUR' })
distributors.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
}
/** Liste des clients pouvant etre choisis comme courtier (code COURTIER). */
async function loadBrokers(): Promise<void> {
if (brokers.value.length > 0) {
return
}
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'COURTIER' })
brokers.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
}
return {
categories,
sites,
tvaModes,
paymentDelays,
paymentTypes,
banks,
distributors,
brokers,
loadCommon,
loadDistributors,
loadBrokers,
}
}