import { computed, reactive, ref, type Ref } from 'vue' import { useFormErrors } from '~/shared/composables/useFormErrors' import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api' import { removeCollectionRow } from '~/shared/utils/collectionRow' import { emptyProviderAccounting, emptyProviderAddress, emptyProviderContact, emptyProviderMain, emptyProviderRib, type ProviderAccountingDraft, type ProviderAddressFormDraft, type ProviderAddressResponse, type ProviderContactFormDraft, type ProviderContactResponse, type ProviderMainDraft, type ProviderMainResponse, type ProviderRibFormDraft, type ProviderRibResponse, } from '~/modules/technique/types/providerForm' import { buildProviderContactPayload, isProviderContactBlank, isProviderContactNamed, } from '~/modules/technique/utils/forms/providerContact' import { buildProviderAddressPayload, isProviderAddressValid, } from '~/modules/technique/utils/forms/providerAddress' import { buildProviderAccountingPayload, buildProviderRibPayload, isRibBlank, isRibComplete, } from '~/modules/technique/utils/forms/providerAccounting' /** * Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) — * miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en * composable. * * Particularites M3 (cf. spec-front § « Ecran Ajouter ») : * - PAS d'onglet « Information » : le formulaire principal est minimal (Nom + * Categorie + Site). * - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe * `provider.sites`). * - Creation incrementale par onglets (Contact · Adresse · Comptabilite) : * POST principal puis PATCH partiels par groupe de serialisation * (`provider:write:*`, mode strict — spec-back § 2.10). Le contenu des onglets * arrive aux tickets ERP-142 → 144 ; ce composable pose le POST principal et * l'orchestration des onglets. * * Etat 100 % local a l'instance (refs/reactive) — aucune persistance URL. */ /** * Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ; * « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification. * L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir * (`technique.providers.accounting.view` — Admin, Compta). */ export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] { return canAccountingView ? ['contact', 'address', 'accounting'] : ['contact', 'address'] } export function useProviderForm() { const api = useApi() const { t } = useI18n() const toast = useToast() const { can } = usePermissions() // Erreurs de validation par champ (ERP-101) du formulaire principal. const mainErrors = useFormErrors() // ERP-172 : remontee d'erreur 409/422 lors d'une suppression immediate de // sous-ressource (message back affiche en toast dedie — pas de mapping inline, // le bloc est en cours de retrait). Ex. dernier RIB d'une LCR -> 409. function notifyRemovalError(error: unknown): void { toast.error({ title: t('technique.providers.toast.error'), message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('technique.providers.toast.error'), }) } // ── Etat du prestataire cree ──────────────────────────────────────────── const providerId = ref(null) const mainLocked = ref(false) const mainSubmitting = ref(false) const tabSubmitting = ref(false) // ── Formulaire principal ────────────────────────────────────────────────── const main = reactive(emptyProviderMain()) // ── Onglets : ordre + gating progressif ─────────────────────────────────── const canAccountingView = computed(() => can('technique.providers.accounting.view')) const canAccountingManage = computed(() => can('technique.providers.accounting.manage')) const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value)) // Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree). const unlockedIndex = ref(-1) const activeTab = ref('contact') // Onglets valides (passent en lecture seule). const validated = reactive>({}) // Mode MODIFICATION (ERP-145) : navigation libre, pas de verrouillage ni de // bascule automatique d'onglet a la validation (cf. completeTab). const editMode = ref(false) function isValidated(key: string): boolean { return validated[key] === true } function tabIndex(key: string): number { return tabKeys.value.indexOf(key) } /** * Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09 * (>= 1 categorie). Pose les erreurs inline et retourne false si invalide. * Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un * aller-retour inutile et porte la garantie RG-3.03 cote front. */ function validateMainFront(): boolean { let valid = true if (!main.companyName?.trim()) { mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired')) valid = false } if (main.siteIris.length === 0) { mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired')) valid = false } if (main.categoryIris.length === 0) { mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired')) valid = false } return valid } /** * Payload du POST principal (groupe `provider:write:main`). `companyName` est * omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur * le champ plutot qu'une erreur de type. Les relations M2M partent en IRI. */ function buildMainPayload(): Record { const payload: Record = { categories: [...main.categoryIris], sites: [...main.siteIris], } if (main.companyName?.trim()) { payload.companyName = main.companyName } return payload } /** * POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09, * puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er * onglet et bascule sur « Contact ». Retourne true si cree, false sinon. */ async function submitMain(): Promise { if (mainSubmitting.value) return false mainErrors.clearErrors() if (!validateMainFront()) return false mainSubmitting.value = true try { const created = await api.post('/providers', buildMainPayload(), { headers: { Accept: 'application/ld+json' }, toast: false, }) providerId.value = created.id // Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11). main.companyName = created.companyName ?? main.companyName mainLocked.value = true unlockedIndex.value = 0 activeTab.value = tabKeys.value[0] ?? 'contact' toast.success({ title: t('technique.providers.toast.createSuccess') }) return true } catch (error) { // 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ; // 422 → mapping inline par champ ; autre → toast de fallback (ERP-101). const status = (error as { response?: { status?: number } })?.response?.status if (status === 409) { const message = t('technique.providers.form.duplicateCompany') mainErrors.setError('companyName', message) toast.error({ title: t('technique.providers.toast.error'), message }) } else { mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') }) } return false } finally { mainSubmitting.value = false } } /** * PATCH partiel du prestataire (mode strict : un seul groupe de serialisation * par appel — spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires * (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources * (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas. */ async function patchProvider(payload: Record): Promise { if (providerId.value === null) return await api.patch(`/providers/${providerId.value}`, payload, { toast: false }) } /** * MODIFICATION du bloc principal (ERP-145) : PATCH /providers/{id} sur le groupe * provider:write:main (nom + categories + sites). Pre-check front RG-3.03/3.09, * 409 doublon de nom (RG-3.10) et 422 mappes inline comme a la creation. A la * difference de `submitMain`, ne verrouille rien et ne bascule pas d'onglet (la * navigation est libre en modification). Retourne true si le PATCH a reussi. */ async function updateMain(): Promise { if (providerId.value === null || mainSubmitting.value) return false mainErrors.clearErrors() if (!validateMainFront()) return false mainSubmitting.value = true try { const updated = await api.patch( `/providers/${providerId.value}`, buildMainPayload(), { toast: false }, ) main.companyName = updated.companyName ?? main.companyName return true } catch (error) { const status = (error as { response?: { status?: number } })?.response?.status if (status === 409) { const message = t('technique.providers.form.duplicateCompany') mainErrors.setError('companyName', message) toast.error({ title: t('technique.providers.toast.error'), message }) } else { mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') }) } return false } finally { mainSubmitting.value = false } } /** * Marque un onglet valide (passe en lecture seule), deverrouille et avance a * l'onglet suivant. Retourne true si c'etait le dernier onglet du flux * (creation terminee), false sinon. */ function completeTab(key: string): boolean { // En modification : navigation libre, l'onglet reste editable apres validation. if (editMode.value) { return false } validated[key] = true const index = tabIndex(key) const next = tabKeys.value[index + 1] if (next === undefined) { return true } unlockedIndex.value = Math.max(unlockedIndex.value, index + 1) activeTab.value = next return false } /** * Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX : * on n'arrete pas au premier bloc en echec (decision ERP-101). Reinitialise la * cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou delegue le * fallback a `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne * true si au moins un bloc a echoue. Miroir de `useSupplierFormErrors.submitRows`. */ async function submitRows( rows: T[], target: Ref[]>, saveRow: (row: T, index: number) => Promise, onUnmappedError: (error: unknown, index: number) => void, shouldSkip?: (row: T, index: number) => boolean, ): Promise { target.value = [] let hasError = false for (let index = 0; index < rows.length; index++) { const row = rows[index] as T if (shouldSkip?.(row, index)) { continue } try { await saveRow(row, index) } catch (error) { const response = (error as { response?: { status?: number, _data?: unknown } })?.response const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {} if (Object.keys(mapped).length > 0) { target.value[index] = mapped } else { onUnmappedError(error, index) } hasError = true } } return hasError } // ── Onglet Contact (ERP-142) ────────────────────────────────────────────── const contacts = ref([emptyProviderContact()]) // Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows. const contactErrors = ref[]>([]) // « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU // prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas). const canAddContact = computed(() => { const last = contacts.value[contacts.value.length - 1] return last !== undefined && isProviderContactNamed(last) }) function addContact(): void { if (canAddContact.value) { contacts.value.push(emptyProviderContact()) } } // ERP-172 : DELETE immediat du contact existant (sous-ressource) a la // confirmation de la modale. Bloc jamais persiste (id null) : retrait local. async function removeContact(index: number): Promise { await removeCollectionRow({ rows: contacts.value, errors: contactErrors.value, index, endpoint: '/provider_contacts', deleteRow: url => api.delete(url, {}, { toast: false }), makeEmpty: emptyProviderContact, onError: notifyRemovalError, }) } /** * Valide l'onglet Contact : POST des nouveaux contacts sur * /providers/{id}/contacts, PATCH des existants sur /provider_contacts/{id} * (sous-ressource, groupe provider:write:contacts). RG-3.12 : au moins un bloc * valide. Si l'onglet ne contient QUE des amorces vides, on les soumet pour * declencher la 422 RG-3.04 inline (sur `firstName`) plutot que de finaliser un * onglet vide. Retourne true si l'onglet a ete valide (avance/termine). */ async function submitContacts(onError: (error: unknown) => void): Promise { if (providerId.value === null || tabSubmitting.value) { return false } tabSubmitting.value = true try { const hasSubmittable = contacts.value.some(c => c.id !== null || !isProviderContactBlank(c)) const hasError = await submitRows( contacts.value, contactErrors, async (contact) => { const body = buildProviderContactPayload(contact) if (contact.id === null) { const created = await api.post( `/providers/${providerId.value}/contacts`, body, { headers: { Accept: 'application/ld+json' }, toast: false }, ) contact.id = created.id contact.iri = created['@id'] ?? null } else { await api.patch(`/provider_contacts/${contact.id}`, body, { toast: false }) } }, onError, contact => hasSubmittable && contact.id === null && isProviderContactBlank(contact), ) if (hasError) { return false } completeTab('contact') return true } finally { tabSubmitting.value = false } } // ── Onglet Adresse (ERP-143) ────────────────────────────────────────────── const addresses = ref([emptyProviderAddress()]) // Erreurs 422 par ligne (alignees sur l'index du v-for). const addressErrors = ref[]>([]) // « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas // au moins un site ET une categorie (RG-3.05 / RG-3.09). const canAddAddress = computed(() => { const last = addresses.value[addresses.value.length - 1] return last !== undefined && isProviderAddressValid(last) }) function addAddress(): void { if (canAddAddress.value) { addresses.value.push(emptyProviderAddress()) } } // ERP-172 : DELETE immediat de l'adresse existante (sous-ressource). async function removeAddress(index: number): Promise { await removeCollectionRow({ rows: addresses.value, errors: addressErrors.value, index, endpoint: '/provider_addresses', deleteRow: url => api.delete(url, {}, { toast: false }), makeEmpty: emptyProviderAddress, onError: notifyRemovalError, }) } /** * Valide l'onglet Adresse : POST des nouvelles adresses sur * /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id} * (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par * ligne. Retourne true si l'onglet a ete valide (avance/termine). */ async function submitAddresses(onError: (error: unknown) => void): Promise { if (providerId.value === null || tabSubmitting.value) { return false } tabSubmitting.value = true try { const hasError = await submitRows( addresses.value, addressErrors, async (address) => { const body = buildProviderAddressPayload(address) if (address.id === null) { const created = await api.post( `/providers/${providerId.value}/addresses`, body, { headers: { Accept: 'application/ld+json' }, toast: false }, ) address.id = created.id } else { await api.patch(`/provider_addresses/${address.id}`, body, { toast: false }) } }, onError, ) if (hasError) { return false } completeTab('address') return true } finally { tabSubmitting.value = false } } // ── Onglet Comptabilite (ERP-144) ───────────────────────────────────────── const accounting = reactive(emptyProviderAccounting()) const ribs = ref([]) const accountingErrors = useFormErrors() // Erreurs 422 par ligne de RIB (alignees sur l'index du v-for). const ribErrors = ref[]>([]) // L'onglet est editable seulement avec accounting.manage (sinon lecture seule). const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value) /** * Met a jour le type de reglement (IRI) en propageant ses RG inter-champs : * - hors VIREMENT (RG-3.07) : on vide la banque (sans objet) ; * - LCR (RG-3.08) : on garantit au moins un bloc RIB visible ; hors LCR, on * purge les erreurs de RIB (les blocs sont conserves mais non persistes). * `isBankRequired` / `isRibRequired` sont calcules par l'appelant (page) a * partir du code resolu via les referentiels. */ function setPaymentType(iri: string | null, isBankRequired: boolean, isRibRequired: boolean): void { accounting.paymentTypeIri = iri if (!isBankRequired) { accounting.bankIri = null } if (isRibRequired) { if (ribs.value.length === 0) { ribs.value.push(emptyProviderRib()) } } else { ribErrors.value = [] } } // « + RIB » desactive tant que le dernier bloc RIB n'est pas complet (RG-3.08). const canAddRib = computed(() => { const last = ribs.value[ribs.value.length - 1] return last !== undefined && isRibComplete(last) }) function addRib(): void { if (canAddRib.value) { ribs.value.push(emptyProviderRib()) } } // ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression // du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve. async function removeRib(index: number): Promise { await removeCollectionRow({ rows: ribs.value, errors: ribErrors.value, index, endpoint: '/provider_ribs', deleteRow: url => api.delete(url, {}, { toast: false }), makeEmpty: emptyProviderRib, onError: notifyRemovalError, }) } /** * Valide l'onglet Comptabilite : (1) sous LCR, POST/PATCH des RIB d'abord * (le back valide RG-3.08 sur le PATCH scalaires, les RIB doivent donc exister * AVANT) ; (2) PATCH des scalaires comptables (groupe provider:write:accounting, * banque envoyee seulement si VIREMENT — RG-3.07). Erreurs RIB par ligne ; * erreurs scalaires inline (bank/paymentType). Retourne true si l'onglet a ete * valide. */ async function submitAccounting( isBankRequired: boolean, isRibRequired: boolean, onRibError: (error: unknown) => void, ): Promise { if (providerId.value === null || tabSubmitting.value) { return false } tabSubmitting.value = true accountingErrors.clearErrors() try { // 1) RIB d'abord, uniquement sous LCR. Une amorce vide neuve est sautee // s'il reste un autre RIB soumettable ; sinon (LCR sans aucun RIB rempli) // on la soumet pour declencher la 422 NotBlank inline. if (isRibRequired) { const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) const ribHasError = await submitRows( ribs.value, ribErrors, async (rib) => { const body = buildProviderRibPayload(rib) if (rib.id === null) { const created = await api.post( `/providers/${providerId.value}/ribs`, body, { headers: { Accept: 'application/ld+json' }, toast: false }, ) rib.id = created.id } else { await api.patch(`/provider_ribs/${rib.id}`, body, { toast: false }) } }, onRibError, rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), ) if (ribHasError) { return false } } // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). try { await api.patch( `/providers/${providerId.value}`, buildProviderAccountingPayload(accounting, isBankRequired), { toast: false }, ) } catch (error) { accountingErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') }) return false } completeTab('accounting') return true } finally { tabSubmitting.value = false } } return { // etat main, providerId, mainLocked, mainSubmitting, tabSubmitting, mainErrors, // onglets canAccountingView, canAccountingManage, tabKeys, activeTab, unlockedIndex, validated, editMode, isValidated, // contacts contacts, contactErrors, canAddContact, addContact, removeContact, submitContacts, // adresses addresses, addressErrors, canAddAddress, addAddress, removeAddress, submitAddresses, // comptabilite accounting, ribs, accountingErrors, ribErrors, accountingReadonly, setPaymentType, canAddRib, addRib, removeRib, submitAccounting, // actions validateMainFront, buildMainPayload, submitMain, updateMain, patchProvider, completeTab, submitRows, } }