import { computed, reactive, ref, type Ref } from 'vue' import { useFormErrors } from '~/shared/composables/useFormErrors' import { mapViolationsToRecord } from '~/shared/utils/api' import { emptyProviderAddress, emptyProviderContact, emptyProviderMain, type ProviderAddressFormDraft, type ProviderAddressResponse, type ProviderContactFormDraft, type ProviderContactResponse, type ProviderMainDraft, type ProviderMainResponse, } from '~/modules/technique/types/providerForm' import { buildProviderContactPayload, isProviderContactBlank, } from '~/modules/technique/utils/forms/providerContact' import { buildProviderAddressPayload, isProviderAddressValid, } from '~/modules/technique/utils/forms/providerAddress' /** * 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() // ── 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 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>({}) 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.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 }) } /** * 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 { 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 est vide (RG-3.04). const canAddContact = computed(() => { const last = contacts.value[contacts.value.length - 1] return last !== undefined && !isProviderContactBlank(last) }) function addContact(): void { if (canAddContact.value) { contacts.value.push(emptyProviderContact()) } } function removeContact(index: number): void { contacts.value.splice(index, 1) contactErrors.value.splice(index, 1) } /** * 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()) } } function removeAddress(index: number): void { addresses.value.splice(index, 1) addressErrors.value.splice(index, 1) } /** * 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 } } return { // etat main, providerId, mainLocked, mainSubmitting, tabSubmitting, mainErrors, // onglets canAccountingView, tabKeys, activeTab, unlockedIndex, validated, isValidated, // contacts contacts, contactErrors, canAddContact, addContact, removeContact, submitContacts, // adresses addresses, addressErrors, canAddAddress, addAddress, removeAddress, submitAddresses, // actions validateMainFront, buildMainPayload, submitMain, patchProvider, completeTab, submitRows, } }