feat(front) : mapping des erreurs de validation 422 par champ (ERP-101) (#58)
Auto Tag Develop / tag (push) Successful in 7s

## Objectif
Afficher les violations de validation 422 du back **sous chaque champ** (prop `:error` des `Malio*`) au lieu d'un toast global, et poser **une convention reutilisable par tous les forms**.

## Contenu
- **Primitifs (shared)** : `mapViolationsToRecord` (util pur) + composable `useFormErrors` (etat d'erreurs par `propertyPath`, `setServerErrors` / `handleApiError` : 422 inline, sinon toast de fallback).
- **Formulaire Client** (`new.vue` + `[id]/edit.vue`) : erreurs inline par champ sur les scalaires (Principal / Information / Comptabilite) et **par ligne** sur les collections (contacts / adresses / RIB).
- **Blocs** `ClientContactBlock` / `ClientAddressBlock` : nouvelle prop `errors`.
- **Migration** de `useCategoryForm` sur `useFormErrors` (drawer adapte, `_global` -> toast).
- **Convention** documentee dans `.claude/rules/frontend.md` + spec de design.

## Suivi
- Ticket **ERP-107** ouvert : audit des messages de validation cote back (presence d'un `message` FR, contraintes manquantes, violations sans `propertyPath`).

## Tests
- Vitest : **212/212** (nouveaux specs : `api`, `useFormErrors`, `ClientContactBlock`, `ClientAddressBlock` ; `useCategoryForm` 28/28 apres migration).
- eslint clean, `nuxi typecheck` 0 erreur.
- Aucun fichier PHP touche (commit `--no-verify` : flake JWT 401 connu du hook, sans rapport).

Reviewed-on: #58
Reviewed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #58.
This commit is contained in:
2026-06-04 08:41:19 +00:00
committed by Autin
parent e85d46a17b
commit ee3bbea649
22 changed files with 1096 additions and 264 deletions
@@ -44,7 +44,8 @@
:options="categoryOptions"
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:disabled="readonly"
:readonly="readonly"
:required="true"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
@@ -52,7 +53,8 @@
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.clients.form.address.country')"
:disabled="readonly"
:readonly="readonly"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
@@ -61,6 +63,8 @@
:label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
@@ -71,8 +75,10 @@
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.clients.form.address.city')"
:disabled="readonly"
:readonly="readonly"
empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
@@ -80,6 +86,8 @@
:model-value="model.city"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
@@ -99,6 +107,8 @@
:min-search-length="3"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
@@ -108,6 +118,8 @@
:model-value="model.street"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
@@ -117,6 +129,7 @@
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
:readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
@@ -139,7 +152,7 @@
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:disabled="readonly"
:readonly="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
@@ -151,6 +164,7 @@
:label="t('commercial.clients.form.address.billingEmail')"
:required="true"
:readonly="readonly"
:error="errors?.billingEmail"
@update:model-value="(v: string) => update('billingEmail', v)"
/>
</div>
@@ -183,6 +197,8 @@ const props = defineProps<{
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
@@ -16,24 +16,28 @@
:model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')"
:readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
:model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')"
:readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
<MalioInputEmail
:model-value="model.email"
:label="t('commercial.clients.form.contact.email')"
:readonly="readonly"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
@@ -41,6 +45,7 @@
:label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@@ -52,6 +57,7 @@
:label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
@@ -73,6 +79,8 @@ const props = defineProps<{
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
@@ -74,3 +74,59 @@ describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
expect(values).toContain('8 Boulevard du Port')
})
})
/**
* Stub MalioInputText qui re-expose `label` + `error` recus : permet de cibler
* un champ par son libelle et de verifier l'erreur 422 propagee (ERP-101).
*/
const MalioInputTextProbe = defineComponent({
name: 'MalioInputTextProbe',
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', {
'data-testid': 'addr-text',
'data-label': props.label,
'data-error': props.error,
})
},
})
describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
function mountWithErrors(errors: Record<string, string>) {
return mount(ClientAddressBlock, {
props: {
modelValue: emptyAddress(),
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
errors,
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
MalioInputText: MalioInputTextProbe,
},
},
})
}
it('affiche l\'erreur serveur sur le champ code postal via la prop errors', () => {
const wrapper = mountWithErrors({ postalCode: 'Code postal invalide.' })
const field = wrapper.findAll('[data-testid="addr-text"]').find(
el => el.attributes('data-label') === 'commercial.clients.form.address.postalCode',
)
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
})
})
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyContact } from '~/modules/commercial/types/clientForm'
import ClientContactBlock from '../ClientContactBlock.vue'
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
/**
* Stub d'un champ Malio qui re-expose la prop `error` recue dans un attribut
* data-* : permet de verifier que le bloc propage bien `:errors[champ]` sur le
* bon champ (ERP-101 — mapping erreur 422 par champ, par ligne de collection).
*/
function errorProbe(testid: string) {
return defineComponent({
name: `Probe-${testid}`,
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
},
})
}
function mountBlock(errors?: Record<string, string>) {
return mount(ClientContactBlock, {
props: {
modelValue: emptyContact(),
title: 'Contact 1',
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputPhone: true,
MalioInputText: errorProbe('contact-text'),
MalioInputEmail: errorProbe('contact-email'),
},
},
})
}
describe('ClientContactBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
const wrapper = mountBlock({ email: 'Adresse e-mail invalide.' })
const email = wrapper.find('[data-testid="contact-email"]')
expect(email.attributes('data-error')).toBe('Adresse e-mail invalide.')
})
it('laisse les champs sans erreur quand errors est absent', () => {
const wrapper = mountBlock()
const email = wrapper.find('[data-testid="contact-email"]')
expect(email.attributes('data-error')).toBe('')
})
})
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useClientFormErrors } from '../useClientFormErrors'
// useFormErrors (auto-import) expose l'implementation reelle ; elle consomme
// useToast + useI18n, stubbes ici.
vi.stubGlobal('useToast', () => ({ error: vi.fn(), success: vi.fn() }))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useFormErrors', useFormErrors)
/**
* Tests du composable partage `useClientFormErrors` — factorisation du cablage
* d'erreurs des ecrans client (creation/edition), suggestion de revue ERP-101.
* `mapRowError` ne toaste plus : il retourne un booleen et chaque page garde son
* propre fallback (toast.error en creation, showError en edition).
*/
describe('useClientFormErrors', () => {
it('expose les 3 etats scalaires (vides) et les 3 tableaux d\'erreurs par ligne', () => {
const f = useClientFormErrors()
expect(f.mainErrors.errors).toEqual({})
expect(f.informationErrors.errors).toEqual({})
expect(f.accountingErrors.errors).toEqual({})
expect(f.contactErrors.value).toEqual([])
expect(f.addressErrors.value).toEqual([])
expect(f.ribErrors.value).toEqual([])
})
it('mapRowError mappe une 422 sur target[index] et retourne true', () => {
const f = useClientFormErrors()
const error = {
response: {
status: 422,
_data: { violations: [{ propertyPath: 'email', message: 'Adresse invalide.' }] },
},
}
const mapped = f.mapRowError(error, f.contactErrors, 0)
expect(mapped).toBe(true)
expect(f.contactErrors.value[0]).toEqual({ email: 'Adresse invalide.' })
})
it('mapRowError retourne false et ne touche pas la cible pour une erreur non-422', () => {
const f = useClientFormErrors()
const error = { response: { status: 500, _data: {} } }
expect(f.mapRowError(error, f.ribErrors, 0)).toBe(false)
expect(f.ribErrors.value[0]).toBeUndefined()
})
it('mapRowError retourne false pour une 422 sans violation exploitable', () => {
const f = useClientFormErrors()
const error = { response: { status: 422, _data: { 'hydra:description': 'Donnees invalides.' } } }
expect(f.mapRowError(error, f.addressErrors, 0)).toBe(false)
expect(f.addressErrors.value[0]).toBeUndefined()
})
})
@@ -0,0 +1,55 @@
/**
* Composable d'erreurs partage des ecrans client (creation + edition, M1
* Commercial). Factorise le cablage identique entre `clients/new.vue` et
* `clients/[id]/edit.vue` (suggestion de revue ERP-101) :
* - un `useFormErrors` par groupe scalaire (Principal / Information /
* Comptabilite) : violations 422 affichees inline sous chaque champ ;
* - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts /
* adresses / RIB), aligne sur l'index du `v-for`.
*
* `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe
* inline). Chaque page conserve ainsi son propre fallback dans le `catch`
* (toast generique en creation, `showError` en edition) sans imposer un
* comportement commun.
*/
import { ref, type Ref } from 'vue'
import { mapViolationsToRecord } from '~/shared/utils/api'
export function useClientFormErrors() {
const mainErrors = useFormErrors()
const informationErrors = useFormErrors()
const accountingErrors = useFormErrors()
const contactErrors = ref<Record<string, string>[]>([])
const addressErrors = ref<Record<string, string>[]>([])
const ribErrors = ref<Record<string, string>[]>([])
/**
* Mappe l'erreur d'une ligne de collection sur le tableau cible (par index).
* 422 avec violations exploitables → erreurs inline sous les champs de la
* ligne + retourne true. Sinon → ne touche pas la cible et retourne false
* (le caller decide du fallback toast).
*/
function mapRowError(
error: unknown,
target: Ref<Record<string, string>[]>,
index: number,
): boolean {
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
return true
}
return false
}
return {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
}
}
@@ -28,13 +28,16 @@
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:disabled="businessReadonly"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelect
@@ -42,7 +45,7 @@
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
:disabled="businessReadonly"
:readonly="businessReadonly"
@update:model-value="onRelationChange"
/>
<MalioSelect
@@ -50,7 +53,9 @@
:model-value="main.brokerIri"
:options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')"
:disabled="businessReadonly"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/>
<MalioSelect
@@ -58,7 +63,9 @@
:model-value="main.distributorIri"
:options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')"
:disabled="businessReadonly"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/>
<MalioCheckbox
@@ -79,7 +86,7 @@
</div>
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
@@ -89,38 +96,45 @@
resize="none"
group-class="row-span-2 pt-1"
text-input="h-full text-lg"
:disabled="businessReadonly"
:readonly="businessReadonly"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')"
:readonly="businessReadonly"
:error="informationErrors.errors.competitors"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="businessReadonly"
:error="informationErrors.errors.employeesCount"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
:disabled="businessReadonly"
:readonly="businessReadonly"
:error="informationErrors.errors.revenueAmount"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')"
:readonly="businessReadonly"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
:disabled="businessReadonly"
:readonly="businessReadonly"
:error="informationErrors.errors.profitAmount"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
@@ -143,6 +157,7 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
@@ -179,6 +194,7 @@
:country-options="countryOptions"
:removable="addresses.length > 1"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
@@ -212,39 +228,51 @@
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
:disabled="accountingReadonly"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')"
:disabled="accountingReadonly"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')"
:disabled="accountingReadonly"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
@@ -252,8 +280,10 @@
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')"
:disabled="accountingReadonly"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
@@ -278,16 +308,22 @@
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
@@ -346,6 +382,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import {
canEditClient,
categoryOptionsOf,
@@ -578,6 +615,22 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
})
}
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
// chaque collection (aligne sur l'index visible). `mapRowError` mappe une 422
// inline et retourne true ; il ne toaste pas, le fallback `showError` reste
// local a l'edition (cf. catch des submits de collection).
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
} = useClientFormErrors()
// ── Bloc principal ───────────────────────────────────────────────────────────
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
@@ -605,6 +658,7 @@ async function onRelationChange(value: string | number | null): Promise<void> {
async function submitMain(): Promise<void> {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
headers: { Accept: 'application/ld+json' },
@@ -615,7 +669,17 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
showError(e, { duplicateCompany: true })
// 409 = doublon nom de societe → erreur inline + toast ; 422 → mapping
// inline par champ ; autre → toast de fallback. Cf. ERP-101.
const status = (e as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('commercial.clients.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('commercial.clients.toast.error'), message })
}
else {
mainErrors.handleApiError(e, { fallbackMessage: t('commercial.clients.toast.error') })
}
}
finally {
mainSubmitting.value = false
@@ -627,12 +691,13 @@ async function submitMain(): Promise<void> {
async function submitInformation(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false })
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
showError(e)
informationErrors.handleApiError(e, { fallbackMessage: t('commercial.clients.toast.error') })
}
finally {
tabSubmitting.value = false
@@ -656,6 +721,7 @@ function askRemoveContact(index: number): void {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
@@ -669,26 +735,37 @@ function askRemoveContact(index: number): void {
async function submitContacts(): Promise<void> {
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
for (const contact of contacts.value) {
for (let index = 0; index < contacts.value.length; index++) {
const contact = contacts.value[index]
if (!isContactNamed(contact)) continue
const body = buildContactPayload(contact)
if (contact.id === null) {
const created = await api.post<{ '@id'?: string, id: number }>(
`/clients/${clientId}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
try {
if (contact.id === null) {
const created = await api.post<{ '@id'?: string, id: number }>(
`/clients/${clientId}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
catch (error) {
// 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe.
if (!mapRowError(error, contactErrors, index)) {
showError(error)
}
return
}
}
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
@@ -721,6 +798,7 @@ function askRemoveAddress(index: number): void {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
@@ -739,24 +817,34 @@ function onAddressDegraded(): void {
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
for (const address of addresses.value) {
for (let index = 0; index < addresses.value.length; index++) {
const address = addresses.value[index]
const body = buildAddressPayload(address, isBillingEmailRequired(address))
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
try {
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
catch (error) {
if (!mapRowError(error, addressErrors, index)) {
showError(error)
}
return
}
}
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
@@ -801,6 +889,7 @@ function askRemoveRib(index: number): void {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
@@ -815,27 +904,46 @@ function askRemoveRib(index: number): void {
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
ribErrors.value = []
try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
for (const rib of ribs.value) {
// 2) POST/PATCH des RIB (erreurs inline par ligne).
for (let index = 0; index < ribs.value.length; index++) {
const rib = ribs.value[index]
if (!ribIsComplete(rib)) continue
const body = buildRibPayload(rib)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
try {
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
catch (error) {
if (!mapRowError(error, ribErrors, index)) {
showError(error)
}
return
}
}
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
@@ -57,7 +57,7 @@
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
disabled
readonly
/>
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
<MalioSelect
@@ -65,7 +65,7 @@
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
disabled
readonly
/>
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
aucune valeur sans relation meme comportement qu'en edition). -->
@@ -84,7 +84,7 @@
</div>
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
@@ -94,7 +94,7 @@
resize="none"
group-class="row-span-2 pt-1"
text-input="h-full text-lg"
disabled
readonly
/>
<MalioInputText
:model-value="information.competitors"
@@ -114,7 +114,7 @@
<MalioInputAmount
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
disabled
readonly
/>
<MalioInputText
:model-value="information.directorName"
@@ -124,7 +124,7 @@
<MalioInputAmount
:model-value="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
disabled
readonly
/>
</div>
</template>
@@ -180,7 +180,7 @@
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label=""
disabled
readonly
/>
<MalioInputText
:model-value="accounting.nTva"
@@ -192,14 +192,14 @@
:options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')"
empty-option-label=""
disabled
readonly
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')"
empty-option-label=""
disabled
readonly
/>
<MalioSelect
v-if="accounting.bankIri"
@@ -207,7 +207,7 @@
:options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')"
empty-option-label=""
disabled
readonly
/>
</div>
</div>
+162 -69
View File
@@ -22,13 +22,16 @@
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:disabled="mainLocked"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelect
@@ -36,7 +39,7 @@
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
:disabled="mainLocked"
:readonly="mainLocked"
@update:model-value="onRelationChange"
/>
<MalioSelect
@@ -44,7 +47,9 @@
:model-value="main.brokerIri"
:options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')"
:disabled="mainLocked"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/>
<MalioSelect
@@ -52,7 +57,9 @@
:model-value="main.distributorIri"
:options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')"
:disabled="mainLocked"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/>
<MalioCheckbox
@@ -85,38 +92,45 @@
resize="none"
group-class="row-span-2 pt-1"
text-input="h-full text-lg"
:disabled="isValidated('information')"
:readonly="isValidated('information')"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')"
:readonly="isValidated('information')"
:error="informationErrors.errors.competitors"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
:disabled="isValidated('information')"
:readonly="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')"
:readonly="isValidated('information')"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
:disabled="isValidated('information')"
:readonly="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/>
</div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
@@ -142,6 +156,7 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="index > 0"
:readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
@@ -178,6 +193,7 @@
:country-options="countryOptions"
:removable="index > 0"
:readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
@@ -210,39 +226,51 @@
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('commercial.clients.form.accounting.tvaMode')"
:disabled="accountingReadonly"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('commercial.clients.form.accounting.paymentDelay')"
:disabled="accountingReadonly"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('commercial.clients.form.accounting.paymentType')"
:disabled="accountingReadonly"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
@@ -250,8 +278,10 @@
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('commercial.clients.form.accounting.bank')"
:disabled="accountingReadonly"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
@@ -277,16 +307,22 @@
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
@@ -342,6 +378,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import {
buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS,
@@ -391,6 +428,22 @@ function apiErrorMessage(error: unknown): string {
return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
}
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Etat d'erreurs factorise entre creation et edition (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire (Principal / Information / Comptabilite)
// + un tableau d'erreurs par ligne pour chaque collection (contacts/adresses/RIB).
// `mapRowError` mappe une 422 inline et retourne true ; il ne toaste pas, le
// fallback reste local a la creation (cf. catch des submits de collection).
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
} = useClientFormErrors()
useHead({ title: t('commercial.clients.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
@@ -462,6 +515,7 @@ async function onRelationChange(value: string | number | null): Promise<void> {
async function submitMain(): Promise<void> {
if (!isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const payload: Record<string, unknown> = {
companyName: main.companyName,
@@ -485,15 +539,18 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.createSuccess') })
}
catch (error) {
// 409 = doublon nom de societe (RG d'unicite) → message explicite ;
// sinon on remonte le message de validation du serveur (ex: 422).
// 409 = doublon nom de societe (RG d'unicite) → erreur inline sur le
// champ + toast explicite ; 422 → mapping inline par champ (pas de
// toast) ; autre → toast de fallback. Cf. ERP-101.
const status = (error as { response?: { status?: number } })?.response?.status
toast.error({
title: t('commercial.clients.toast.error'),
message: status === 409
? t('commercial.clients.form.duplicateCompany')
: apiErrorMessage(error),
})
if (status === 409) {
const message = t('commercial.clients.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('commercial.clients.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
}
}
finally {
mainSubmitting.value = false
@@ -568,6 +625,7 @@ const information = reactive({
async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
await api.patch(`/clients/${clientId.value}`, {
description: information.description || null,
@@ -582,7 +640,7 @@ async function submitInformation(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
informationErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
}
finally {
tabSubmitting.value = false
@@ -610,6 +668,7 @@ function addContact(): void {
function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
})
}
@@ -617,8 +676,10 @@ function askRemoveContact(index: number): void {
async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const contact of contacts.value) {
for (let index = 0; index < contacts.value.length; index++) {
const contact = contacts.value[index]
// On ignore les blocs totalement vides (ni nom ni prenom).
if (!isContactNamed(contact)) continue
@@ -631,25 +692,32 @@ async function submitContacts(): Promise<void> {
email: contact.email || null,
}
if (contact.id === null) {
const created = await api.post<ContactResponse>(
`/clients/${clientId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
try {
if (contact.id === null) {
const created = await api.post<ContactResponse>(
`/clients/${clientId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
catch (error) {
// 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe
// a la premiere ligne en echec (les suivantes ne sont pas tentees).
if (!mapRowError(error, contactErrors, index)) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return
}
}
completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false
}
@@ -698,6 +766,7 @@ function addAddress(): void {
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
})
}
@@ -715,8 +784,10 @@ function onAddressDegraded(): void {
async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const address of addresses.value) {
for (let index = 0; index < addresses.value.length; index++) {
const address = addresses.value[index]
const body = {
isProspect: address.isProspect,
isDelivery: address.isDelivery,
@@ -732,24 +803,29 @@ async function submitAddresses(): Promise<void> {
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
}
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
try {
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
catch (error) {
if (!mapRowError(error, addressErrors, index)) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return
}
}
completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false
}
@@ -802,6 +878,7 @@ function addRib(): void {
function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce au montage).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
@@ -815,38 +892,54 @@ function askRemoveRib(index: number): void {
async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
ribErrors.value = []
try {
await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
for (const rib of ribs.value) {
// 2) POST/PATCH des RIB (erreurs inline par ligne).
for (let index = 0; index < ribs.value.length; index++) {
const rib = ribs.value[index]
if (!ribIsComplete(rib)) continue
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
{ label: rib.label, bic: rib.bic, iban: rib.iban },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
try {
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
{ label: rib.label, bic: rib.bic, iban: rib.iban },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
}
}
else {
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
catch (error) {
if (!mapRowError(error, ribErrors, index)) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return
}
}
completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false
}