@@ -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"
/>
@@ -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"
/>
accounting.tvaModeIri = v === null ? null : String(v)"
/>
accounting.paymentDelayIri = v === null ? null : String(v)"
/>
accounting.bankIri = v === null ? null : String(v)"
/>
@@ -278,16 +308,22 @@
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
+ :required="isRibRequired"
+ :error="ribErrors[index]?.label"
/>
@@ -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 {
async function submitMain(): Promise {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true
+ mainErrors.clearErrors()
try {
const updated = await api.patch(`/clients/${clientId}`, buildMainPayload(main), {
headers: { Accept: 'application/ld+json' },
@@ -615,7 +669,17 @@ async function submitMain(): Promise {
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 {
async function submitInformation(): Promise {
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 {
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 {
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 {
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') })
diff --git a/frontend/modules/commercial/pages/clients/[id]/index.vue b/frontend/modules/commercial/pages/clients/[id]/index.vue
index ab74193..739ebda 100644
--- a/frontend/modules/commercial/pages/clients/[id]/index.vue
+++ b/frontend/modules/commercial/pages/clients/[id]/index.vue
@@ -57,7 +57,7 @@
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
- disabled
+ readonly
/>
@@ -84,7 +84,7 @@
-
+
@@ -94,7 +94,7 @@
resize="none"
group-class="row-span-2 pt-1"
text-input="h-full text-lg"
- disabled
+ readonly
/>
@@ -180,7 +180,7 @@
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label=""
- disabled
+ readonly
/>
diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue
index e29da36..e4680e0 100644
--- a/frontend/modules/commercial/pages/clients/new.vue
+++ b/frontend/modules/commercial/pages/clients/new.vue
@@ -22,13 +22,16 @@
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="mainLocked"
+ :error="mainErrors.errors.companyName"
/>
main.categoryIris = v.map(String)"
/>
main.brokerIri = v === null ? null : String(v)"
/>
main.distributorIri = v === null ? null : String(v)"
/>
@@ -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"
/>
accounting.tvaModeIri = v === null ? null : String(v)"
/>
accounting.paymentDelayIri = v === null ? null : String(v)"
/>
accounting.bankIri = v === null ? null : String(v)"
/>
@@ -277,16 +307,22 @@
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
+ :required="isRibRequired"
+ :error="ribErrors[index]?.label"
/>
@@ -342,6 +378,7 @@