feat(front) : mapping des erreurs de validation 422 par champ (ERP-101)
- composable useFormErrors + util mapViolationsToRecord (shared) - formulaire Client (new + edit) : erreurs inline par champ (scalaires) et par ligne pour les collections (contacts / adresses / RIB) - blocs ClientContactBlock / ClientAddressBlock : prop errors - migration de useCategoryForm sur useFormErrors - convention documentee dans .claude/rules/frontend.md
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
@@ -29,6 +30,7 @@
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
/>
|
||||
<MalioSelect
|
||||
@@ -45,6 +47,7 @@
|
||||
:options="referentials.brokers.value"
|
||||
:label="t('commercial.clients.form.main.brokerName')"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.broker"
|
||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
@@ -53,6 +56,7 @@
|
||||
:options="referentials.distributors.value"
|
||||
:label="t('commercial.clients.form.main.distributorName')"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.distributor"
|
||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
@@ -86,37 +90,44 @@
|
||||
group-class="row-span-2 pt-1"
|
||||
text-input="h-full text-lg"
|
||||
:disabled="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')"
|
||||
: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')"
|
||||
:error="informationErrors.errors.profitAmount"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
||||
@@ -142,6 +153,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 +190,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,11 +223,13 @@
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
@@ -222,12 +237,14 @@
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
: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"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
@@ -235,6 +252,7 @@
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
@@ -243,6 +261,7 @@
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@update:model-value="onPaymentTypeChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
@@ -252,6 +271,7 @@
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:error="accountingErrors.errors.bank"
|
||||
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||
/>
|
||||
</div>
|
||||
@@ -277,16 +297,19 @@
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,7 +363,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch, type Ref } from 'vue'
|
||||
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||
import {
|
||||
buildClientFormTabKeys,
|
||||
@@ -359,7 +382,7 @@ import {
|
||||
type ContactFormDraft,
|
||||
type RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
@@ -391,6 +414,38 @@ function apiErrorMessage(error: unknown): string {
|
||||
return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
|
||||
}
|
||||
|
||||
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
|
||||
// Un `useFormErrors` par groupe scalaire (submit independant) : les violations
|
||||
// 422 du serveur s'affichent sous le champ concerne (prop `:error`) au lieu d'un
|
||||
// simple toast. Les collections (contacts/adresses/RIB) portent une erreur PAR
|
||||
// LIGNE via des tableaux alignes sur l'index, peuples par `mapRowError`.
|
||||
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 d'erreurs cible (par
|
||||
* index). 422 avec violations exploitables → erreurs inline sous les champs de
|
||||
* la ligne ; sinon → toast de fallback. Renvoie true si mappee inline.
|
||||
*/
|
||||
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
|
||||
}
|
||||
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
||||
return false
|
||||
}
|
||||
|
||||
useHead({ title: t('commercial.clients.form.title') })
|
||||
|
||||
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
|
||||
@@ -462,6 +517,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 +541,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 +627,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 +642,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 +670,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 +678,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 +694,30 @@ 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).
|
||||
mapRowError(error, contactErrors, index)
|
||||
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,27 @@ 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) {
|
||||
mapRowError(error, addressErrors, index)
|
||||
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 +876,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 +890,52 @@ 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) {
|
||||
mapRowError(error, ribErrors, index)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user