feat(front) : mapping des erreurs de validation 422 par champ (ERP-101)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m40s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m10s

- 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:
2026-06-04 08:24:39 +02:00
parent e85d46a17b
commit 502d1a216b
15 changed files with 910 additions and 212 deletions
+34
View File
@@ -44,6 +44,40 @@ Tout champ de formulaire / filtre doit utiliser les composants `Malio*` plutot q
Toute autre exception requiert validation avant merge. Toute autre exception requiert validation avant merge.
## Validation des formulaires — useFormErrors obligatoire (erreur par champ)
**Tout formulaire qui soumet a une API DOIT afficher les erreurs de validation 422 sous le champ concerne, via `useFormErrors`** (`frontend/shared/composables/useFormErrors.ts`). C'est le pendant front de « le back renvoie TOUTES les violations d'une 422 d'un coup » : un seul aller-retour, chaque erreur affichee inline sous son champ (prop `:error` des `Malio*`), pas un toast fourre-tout.
Principe cle : **le nom du champ cote front = le `propertyPath` renvoye par le back**. Aucun mapping manuel champ par champ.
Pattern de reference (champs scalaires) :
```ts
const { errors, setError, clearErrors, handleApiError } = useFormErrors()
async function submit() {
clearErrors()
try {
await useApi().post('/clients', payload, { toast: false }) // toast: false obligatoire
} catch (e) {
// 422 → mapping inline par champ (pas de toast) ; autre → toast de fallback.
handleApiError(e, { fallbackMessage: t('foo.error') })
}
}
```
```vue
<MalioInputText v-model="form.companyName" :error="errors.companyName" />
<MalioSelect v-model="form.siren" :error="errors.siren" />
```
Regles :
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
## Tableaux de donnees — MalioDataTable obligatoire ## Tableaux de donnees — MalioDataTable obligatoire
Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` : Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` :
@@ -0,0 +1,119 @@
# ERP-101 — Mapping des erreurs de validation par champ (convention forms)
> Statut : design validé — implémentation TDD en cours
> Branche : `feat/ERP-101-form-field-validation-mapping`
> Date : 2026-06-03
## Problème
Quand le back renvoie une **422** (violations API Platform), il renvoie **toutes** les
violations d'un coup (un `propertyPath` + `message` par champ fautif). Aujourd'hui, seul
le drawer Catégorie (`useCategoryForm`) exploite ce détail pour afficher l'erreur **sous
le champ concerné** ; il le fait via un `if/else` manuel par champ, non réutilisable.
Le formulaire Client (≈ 20 champs sur 5 submits, dont 3 collections) ne mappe rien : une
422 multi-champs ⇒ un seul **toast global**. On veut un retour par champ, et surtout
**une convention unique réutilisée par tous les modules**.
## Décisions
1. **Primitif générique** plutôt que composable par form : `useFormErrors()` partagé.
2. **Périmètre complet** sur Client : champs scalaires **et** collections (erreur par ligne).
## Architecture — 3 briques
### 1. `mapViolationsToRecord(data)` — `frontend/shared/utils/api.ts`
Util pur, fondation réutilisée partout. Transforme un payload 422 en
`Record<propertyPath, message>`. S'appuie sur `extractApiViolations` (déjà existant,
gère les formats `violations` et `hydra:violations`).
```ts
export function mapViolationsToRecord(data: unknown): Record<string, string> {
const out: Record<string, string> = {}
for (const v of extractApiViolations(data)) {
if (v.propertyPath) out[v.propertyPath] = v.message
}
return out
}
```
### 2. `useFormErrors()` — `frontend/shared/composables/useFormErrors.ts`
API que tous les forms **scalaires** consomment.
```ts
const { errors, hasErrors, setServerErrors, setError, clearError, clearErrors, handleApiError } = useFormErrors()
```
- `errors` : `reactive<Record<string, string>>` indexé par `propertyPath`.
- `setServerErrors(data)` : `mapViolationsToRecord` → remplit `errors`. Retourne `true`
si au moins une violation a été mappée.
- `setError(field, msg)` / `clearError(field)` / `clearErrors()` : manipulation fine.
- `hasErrors` : `computed` booléen.
- `handleApiError(e, opts?)` : dispatch standard depuis une erreur ofetch —
**422**`setServerErrors` (mapping inline, pas de toast) ; **autre** → toast
générique de fallback (message extrait via `extractApiErrorMessage`).
Côté template, le nom du champ **est** le `propertyPath` :
```vue
<MalioInputText v-model="main.companyName" :error="errors.companyName" />
<MalioInputText v-model="accounting.siren" :error="errors.siren" />
```
> L'unicité SIREN (RG-1.15) remonte en **422 `UniqueEntity` avec `propertyPath: "siren"`**
> → mappée automatiquement. Pas de cas 409 spécial (contrairement à Catégorie).
### 3. Collections — erreurs par ligne
Chaque ligne (contact / adresse / RIB) est persistée par **son propre appel API**, donc
le back renvoie un 422 **relatif à la sous-entité** (`propertyPath: "email"`, `"iban"`…).
- Le parent tient, par collection, un tableau d'erreurs **aligné sur l'index de ligne** :
`const contactErrors = ref<Record<string, string>[]>([])`.
- Au submit de la ligne `i` : `catch``contactErrors.value[i] = mapViolationsToRecord(data)`.
- On `clearErrors` la collection au début de chaque passe de submit.
- Les blocs reçoivent une prop `:errors` (`Record<string, string>`) et bindent
`:error="errors?.email"` sur chaque champ Malio.
## Fichiers touchés
| Fichier | Action |
|---|---|
| `shared/utils/api.ts` | + `mapViolationsToRecord` |
| `shared/composables/useFormErrors.ts` | **nouveau** composable |
| `modules/commercial/pages/clients/new.vue` | scalaires (Main/Info/Compta) + erreurs par ligne |
| `modules/commercial/pages/clients/[id]/edit.vue` | idem |
| `modules/commercial/components/ClientContactBlock.vue` | + prop `:errors`, bind `:error` |
| `modules/commercial/components/ClientAddressBlock.vue` | + prop `:errors`, bind `:error` |
| RIB (inline dans new/edit) | bind `:error` par ligne |
## Tests (Vitest — règle « pas d'E2E »)
- `mapViolationsToRecord` : formats `violations` / `hydra:violations`, payload vide,
`propertyPath` manquant.
- `useFormErrors` : `setServerErrors` mappe et retourne `true` / `false` sans violation,
`clearErrors`, fallback toast sur non-422.
## Convention posée pour tous les forms
À reporter dans `.claude/rules/frontend.md` une fois le pattern stabilisé :
> Tout form qui veut un retour d'erreur par champ : appels API en `{ toast: false }` +
> `useFormErrors` pour les champs scalaires (422 inline), `mapViolationsToRecord` par
> ligne pour les collections. `useCategoryForm` migrera sur `useFormErrors`.
## Fait dans la foulée (post-ERP-101 initial)
- **`useCategoryForm` migré sur `useFormErrors`** : `errors` devient le `reactive` du
composable (drawer adapté : `form.errors.name` au lieu de `form.errors.value.name`,
bloc `_global` retiré → erreur transverse en toast). 28 tests verts.
- **Convention reportée dans `.claude/rules/frontend.md`** (section « Validation des
formulaires — useFormErrors obligatoire »).
## Hors scope ERP-101 (suivi : ticket ERP-107)
- Langue / présence des messages de validation côté back : le `message` affiché est celui
renvoyé par le serveur. Audit des contraintes Symfony (présence d'un `message` FR,
contraintes manquantes, violations sans `propertyPath`) tracké dans **ERP-107**.
@@ -20,7 +20,7 @@
:label="t('admin.categories.form.name')" :label="t('admin.categories.form.name')"
input-class="w-full" input-class="w-full"
:max-length="120" :max-length="120"
:error="form.errors.value.name" :error="form.errors.name"
required required
/> />
@@ -32,15 +32,9 @@
:options="typeOptions" :options="typeOptions"
:label="t('admin.categories.form.type')" :label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')" :empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="form.errors.value.categoryType" :error="form.errors.categoryType"
:disabled="loadingTypes" :disabled="loadingTypes"
/> />
<!-- Erreur transverse (typiquement reseau / 5xx) separe des
erreurs de validation par champ. -->
<p v-if="form.errors.value._global" class="text-sm text-red-600">
{{ form.errors.value._global }}
</p>
</form> </form>
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body <!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Category, CategoryType } from '~/modules/catalog/types/category' import type { Category, CategoryType } from '~/modules/catalog/types/category'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useCategoryForm } from '../useCategoryForm' import { useCategoryForm } from '../useCategoryForm'
// Stubs des auto-imports Nuxt consommes par le composable. // Stubs des auto-imports Nuxt consommes par le composable.
@@ -21,6 +22,9 @@ vi.stubGlobal('useToast', () => ({
success: mockToastSuccess, success: mockToastSuccess,
error: mockToastError, error: mockToastError,
})) }))
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
// (elle consomme useToast, deja stubbe ci-dessus) pour tester l'integration.
vi.stubGlobal('useFormErrors', useFormErrors)
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus). // useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
// Quand le composable passe des params (ex: doublon), on les serialise pour // Quand le composable passe des params (ex: doublon), on les serialise pour
// pouvoir verifier que l'interpolation a bien recu le bon nom. // pouvoir verifier que l'interpolation a bien recu le bon nom.
@@ -61,7 +65,7 @@ describe('useCategoryForm', () => {
expect(form.name.value).toBe('Vis') expect(form.name.value).toBe('Vis')
expect(form.categoryTypeId.value).toBe(1) expect(form.categoryTypeId.value).toBe(1)
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' }) expect(form.errors).toEqual({})
}) })
it('vide le formulaire en mode creation (null)', () => { it('vide le formulaire en mode creation (null)', () => {
@@ -105,7 +109,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired') expect(form.errors.name).toBe('admin.categories.validation.nameRequired')
}) })
it('signale erreur si name est whitespace-only (trim → vide)', () => { it('signale erreur si name est whitespace-only (trim → vide)', () => {
@@ -116,7 +120,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired') expect(form.errors.name).toBe('admin.categories.validation.nameRequired')
}) })
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => { it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
@@ -127,7 +131,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength') expect(form.errors.name).toBe('admin.categories.validation.nameLength')
}) })
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => { it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
@@ -138,7 +142,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength') expect(form.errors.name).toBe('admin.categories.validation.nameLength')
}) })
it('signale erreur si categoryTypeId est null (RG-1.05)', () => { it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
@@ -149,7 +153,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired') expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
}) })
it('passe quand name et categoryType sont valides', () => { it('passe quand name et categoryType sont valides', () => {
@@ -160,19 +164,22 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(true) expect(ok).toBe(true)
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' }) expect(form.errors).toEqual({})
}) })
it('reinitialise les erreurs avant chaque validation', () => { it('reinitialise les erreurs avant chaque validation', () => {
const form = useCategoryForm() const form = useCategoryForm()
// Erreur prealable. // Erreur prealable : une validation en echec peuple errors.name.
form.errors.value._global = 'erreur ancienne' form.name.value = ''
form.name.value = 'Vis'
form.categoryTypeId.value = 1 form.categoryTypeId.value = 1
form.validate()
expect(form.errors.name).toBeTruthy()
// Seconde validation avec des valeurs valides : errors repart vide.
form.name.value = 'Vis'
form.validate() form.validate()
expect(form.errors.value._global).toBe('') expect(form.errors).toEqual({})
}) })
}) })
@@ -231,8 +238,8 @@ describe('useCategoryForm', () => {
expect(result).toBeNull() expect(result).toBeNull()
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans // La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
// les params i18n (stub serialise les params). // les params i18n (stub serialise les params).
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate') expect(form.errors.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Vis"') expect(form.errors.name).toContain('"name":"Vis"')
expect(mockToastError).toHaveBeenCalledTimes(1) expect(mockToastError).toHaveBeenCalledTimes(1)
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string } const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
expect(toastArg.message).toContain('Vis') expect(toastArg.message).toContain('Vis')
@@ -256,7 +263,7 @@ describe('useCategoryForm', () => {
const result = await form.submitCreate() const result = await form.submitCreate()
expect(result).toBeNull() expect(result).toBeNull()
expect(form.errors.value.name).toBe('name should not be blank.') expect(form.errors.name).toBe('name should not be blank.')
// Pas de toast quand on a mappe les violations : l erreur est // Pas de toast quand on a mappe les violations : l erreur est
// affichee inline sous le champ concerne. // affichee inline sous le champ concerne.
expect(mockToastError).not.toHaveBeenCalled() expect(mockToastError).not.toHaveBeenCalled()
@@ -279,10 +286,10 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
expect(form.errors.value.categoryType).toBe('Type invalide.') expect(form.errors.categoryType).toBe('Type invalide.')
}) })
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => { it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
mockPost.mockRejectedValueOnce({ mockPost.mockRejectedValueOnce({
response: { status: 500, _data: { 'hydra:description': 'Boom server' } }, response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
}) })
@@ -292,7 +299,8 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
expect(form.errors.value._global).toBe('Boom server') // Pas d'erreur inline par champ : l'erreur transverse part en toast.
expect(form.errors).toEqual({})
expect(mockToastError).toHaveBeenCalledWith({ expect(mockToastError).toHaveBeenCalledWith({
title: 'Erreur', title: 'Erreur',
message: 'Boom server', message: 'Boom server',
@@ -386,8 +394,8 @@ describe('useCategoryForm', () => {
const result = await form.submitUpdate(42) const result = await form.submitUpdate(42)
expect(result).toBeNull() expect(result).toBeNull()
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate') expect(form.errors.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Doublon"') expect(form.errors.name).toContain('"name":"Doublon"')
}) })
}) })
@@ -415,7 +423,6 @@ describe('useCategoryForm', () => {
const ok = await form.submitDelete(42) const ok = await form.submitDelete(42)
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value._global).toBe('down')
expect(mockToastError).toHaveBeenCalled() expect(mockToastError).toHaveBeenCalled()
}) })
}) })
@@ -424,15 +431,15 @@ describe('useCategoryForm', () => {
it('vide le formulaire et les erreurs', () => { it('vide le formulaire et les erreurs', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom(CAT) form.loadFrom(CAT)
form.name.value = 'edit' form.name.value = ''
form.errors.value._global = 'erreur' form.validate() // peuple errors.name
form.submitting.value = true form.submitting.value = true
form.reset() form.reset()
expect(form.name.value).toBe('') expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull() expect(form.categoryTypeId.value).toBeNull()
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' }) expect(form.errors).toEqual({})
expect(form.submitting.value).toBe(false) expect(form.submitting.value).toBe(false)
}) })
}) })
@@ -12,14 +12,13 @@
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur * elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
* revalide toujours (defense en profondeur). * revalide toujours (defense en profondeur).
* *
* Mapping erreurs API : * Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
* - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name` * violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ;
* - 422 (violations API Platform) → mapping sur les champs concernes * l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
* - autre erreur globale `_global` + toast generique * RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast.
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Category } from '~/modules/catalog/types/category' import type { Category } from '~/modules/catalog/types/category'
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
/** /**
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici * Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
@@ -37,6 +36,9 @@ export function useCategoryForm() {
const { t } = useI18n() const { t } = useI18n()
const toast = useToast() const toast = useToast()
// Etat d'erreurs par champ (indexe par propertyPath) + dispatch API 422.
const formErrors = useFormErrors()
// State local du formulaire — pas singleton, chaque appel a useCategoryForm // State local du formulaire — pas singleton, chaque appel a useCategoryForm
// cree son propre state (cohérent avec le pattern « un drawer = un form »). // cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('') const name = ref('')
@@ -48,16 +50,6 @@ export function useCategoryForm() {
const initialName = ref('') const initialName = ref('')
const initialCategoryTypeId = ref<number | null>(null) const initialCategoryTypeId = ref<number | null>(null)
const errors = ref<{
name: string
categoryType: string
_global: string
}>({
name: '',
categoryType: '',
_global: '',
})
const submitting = ref(false) const submitting = ref(false)
const isDirty = computed( const isDirty = computed(
@@ -72,7 +64,7 @@ export function useCategoryForm() {
* erreurs et le snapshot initial pour repartir d'un etat propre. * erreurs et le snapshot initial pour repartir d'un etat propre.
*/ */
function loadFrom(category: Category | null): void { function loadFrom(category: Category | null): void {
errors.value = { name: '', categoryType: '', _global: '' } formErrors.clearErrors()
if (category) { if (category) {
name.value = category.name name.value = category.name
categoryTypeId.value = category.categoryType.id categoryTypeId.value = category.categoryType.id
@@ -92,32 +84,29 @@ export function useCategoryForm() {
* mais le serveur retrim de toute facon — pas de risque de divergence. * mais le serveur retrim de toute facon — pas de risque de divergence.
*/ */
function validate(): boolean { function validate(): boolean {
errors.value = { name: '', categoryType: '', _global: '' } formErrors.clearErrors()
const trimmedName = name.value.trim() const trimmedName = name.value.trim()
// RG-1.02 — name obligatoire (vide / whitespace-only). // RG-1.02 — name obligatoire (vide / whitespace-only).
if (trimmedName === '') { if (trimmedName === '') {
errors.value.name = t('admin.categories.validation.nameRequired') formErrors.setError('name', t('admin.categories.validation.nameRequired'))
} else if (trimmedName.length < 2 || trimmedName.length > 120) { } else if (trimmedName.length < 2 || trimmedName.length > 120) {
// RG-1.04 — longueur 2-120 apres trim. // RG-1.04 — longueur 2-120 apres trim.
errors.value.name = t('admin.categories.validation.nameLength') formErrors.setError('name', t('admin.categories.validation.nameLength'))
} }
// RG-1.05 — categoryType obligatoire. // RG-1.05 — categoryType obligatoire.
if (categoryTypeId.value === null) { if (categoryTypeId.value === null) {
errors.value.categoryType = t('admin.categories.validation.typeRequired') formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
} }
return errors.value.name === '' && errors.value.categoryType === '' return !formErrors.errors.name && !formErrors.errors.categoryType
} }
/** /**
* Construit le payload POST a partir du state. Le `categoryType` est * Construit le payload POST a partir du state. Le `categoryType` est
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API * envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
* Platform pour referencer une ressource liee. Retourne un object literal * Platform pour referencer une ressource liee.
* compatible avec `AnyObject` de `useApi()` (un type nomme strict comme
* `CategoryCreateInput` ne serait pas assignable a `Record<string, unknown>`
* en TS strict).
*/ */
function buildCreatePayload(): Record<string, unknown> { function buildCreatePayload(): Record<string, unknown> {
return { return {
@@ -127,72 +116,24 @@ export function useCategoryForm() {
} }
/** /**
* Mappe les violations 422 d'API Platform sur les champs du formulaire. * Traite une erreur API : 409 (doublon RG-1.07) → erreur inline sur `name`
* Renvoie true des qu'au moins une violation a ete posee — false sinon * + toast ; sinon delegue a `useFormErrors.handleApiError` (422 mappe inline
* (payload sans violations exploitables, ou tous les `propertyPath` hors * par champ sans toast, autre → toast de fallback). Retourne true si traitee
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`) * inline (409/422 mappe), false si fallback toast.
* est centralisee dans `shared/utils/api.ts` pour rester reutilisable
* sur les futurs drawers de formulaire.
*/
function mapServerViolations(data: unknown): boolean {
const violations = extractApiViolations(data)
if (violations.length === 0) return false
let mapped = false
for (const v of violations) {
if (v.propertyPath === 'name') {
errors.value.name = v.message
mapped = true
} else if (v.propertyPath === 'categoryType') {
errors.value.categoryType = v.message
mapped = true
}
}
return mapped
}
/**
* Traite une erreur API : mappe selon le status, declenche les toasts
* appropries. Centralise la logique entre create/update.
*
* - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
* le nom soumis.
* - 422 : tentative de mapping fin via les violations API Platform — si au
* moins une violation est mappee, pas de toast (erreur affichee inline
* sous le champ concerne).
* - autre : message global + toast generique. Le toast natif d'useApi
* est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
* donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
*
* Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
* false sinon (fallback generique).
*/ */
function handleApiError(e: unknown, attemptedName: string): boolean { function handleApiError(e: unknown, attemptedName: string): boolean {
const status = (e as ApiFetchError)?.response?.status const status = (e as ApiFetchError)?.response?.status
const data = (e as ApiFetchError)?.response?._data
if (status === 409) { if (status === 409) {
const duplicateMessage = t('admin.categories.toast.duplicate', { const duplicateMessage = t('admin.categories.toast.duplicate', {
name: attemptedName, name: attemptedName,
}) })
errors.value.name = duplicateMessage formErrors.setError('name', duplicateMessage)
toast.error({ toast.error({ title: 'Erreur', message: duplicateMessage })
title: 'Erreur',
message: duplicateMessage,
})
return true return true
} }
if (status === 422 && mapServerViolations(data)) { return formErrors.handleApiError(e, { fallbackMessage: 'Une erreur est survenue.' })
return true
}
const extracted = extractApiErrorMessage(data)
errors.value._global = extracted || 'Une erreur est survenue.'
toast.error({
title: 'Erreur',
message: errors.value._global,
})
return false
} }
/** /**
@@ -203,7 +144,6 @@ export function useCategoryForm() {
async function submitCreate(): Promise<Category | null> { async function submitCreate(): Promise<Category | null> {
if (!validate()) return null if (!validate()) return null
submitting.value = true submitting.value = true
errors.value._global = ''
const payload = buildCreatePayload() const payload = buildCreatePayload()
try { try {
const created = await api.post<Category>('/categories', payload, { const created = await api.post<Category>('/categories', payload, {
@@ -230,7 +170,6 @@ export function useCategoryForm() {
async function submitUpdate(id: number): Promise<Category | null> { async function submitUpdate(id: number): Promise<Category | null> {
if (!validate()) return null if (!validate()) return null
submitting.value = true submitting.value = true
errors.value._global = ''
const payload: Record<string, unknown> = {} const payload: Record<string, unknown> = {}
if (name.value !== initialName.value) { if (name.value !== initialName.value) {
payload.name = name.value.trim() payload.name = name.value.trim()
@@ -272,7 +211,7 @@ export function useCategoryForm() {
*/ */
async function submitDelete(id: number): Promise<boolean> { async function submitDelete(id: number): Promise<boolean> {
submitting.value = true submitting.value = true
errors.value._global = '' formErrors.clearErrors()
try { try {
await api.delete(`/categories/${id}`, {}, { toast: false }) await api.delete(`/categories/${id}`, {}, { toast: false })
toast.success({ toast.success({
@@ -297,7 +236,7 @@ export function useCategoryForm() {
categoryTypeId.value = null categoryTypeId.value = null
initialName.value = '' initialName.value = ''
initialCategoryTypeId.value = null initialCategoryTypeId.value = null
errors.value = { name: '', categoryType: '', _global: '' } formErrors.clearErrors()
submitting.value = false submitting.value = false
} }
@@ -305,7 +244,7 @@ export function useCategoryForm() {
// State // State
name, name,
categoryTypeId, categoryTypeId,
errors, errors: formErrors.errors,
submitting, submitting,
isDirty, isDirty,
// Methods // Methods
@@ -61,6 +61,7 @@
:label="t('commercial.clients.form.address.postalCode')" :label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -73,6 +74,7 @@
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:disabled="readonly" :disabled="readonly"
empty-option-label="" empty-option-label=""
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
@@ -80,6 +82,7 @@
:model-value="model.city" :model-value="model.city"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:readonly="readonly" :readonly="readonly"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -99,6 +102,7 @@
:min-search-length="3" :min-search-length="3"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:error="errors?.street"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch" @search="onAddressSearch"
@select="onAddressSelect" @select="onAddressSelect"
@@ -108,6 +112,7 @@
:model-value="model.street" :model-value="model.street"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
@@ -117,6 +122,7 @@
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')" :label="t('commercial.clients.form.address.streetComplement')"
:readonly="readonly" :readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
</div> </div>
@@ -151,6 +157,7 @@
:label="t('commercial.clients.form.address.billingEmail')" :label="t('commercial.clients.form.address.billingEmail')"
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:error="errors?.billingEmail"
@update:model-value="(v: string) => update('billingEmail', v)" @update:model-value="(v: string) => update('billingEmail', v)"
/> />
</div> </div>
@@ -183,6 +190,8 @@ const props = defineProps<{
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -16,24 +16,28 @@
:model-value="model.lastName" :model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')" :label="t('commercial.clients.form.contact.lastName')"
:readonly="readonly" :readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
:model-value="model.firstName" :model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')" :label="t('commercial.clients.form.contact.firstName')"
:readonly="readonly" :readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')" :label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly" :readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
<MalioInputEmail <MalioInputEmail
:model-value="model.email" :model-value="model.email"
:label="t('commercial.clients.form.contact.email')" :label="t('commercial.clients.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
/> />
<MalioInputPhone <MalioInputPhone
@@ -41,6 +45,7 @@
:label="t('commercial.clients.form.contact.phonePrimary')" :label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')" :add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)" @update:model-value="(v: string) => update('phonePrimary', v)"
@@ -52,6 +57,7 @@
:label="t('commercial.clients.form.contact.phoneSecondary')" :label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
</div> </div>
@@ -73,6 +79,8 @@ const props = defineProps<{
removable?: boolean removable?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -74,3 +74,59 @@ describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
expect(values).toContain('8 Boulevard du Port') 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('')
})
})
@@ -28,6 +28,7 @@
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
@@ -35,6 +36,7 @@
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="businessReadonly" :disabled="businessReadonly"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/> />
<MalioSelect <MalioSelect
@@ -51,6 +53,7 @@
:options="brokerOptions" :options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:disabled="businessReadonly" :disabled="businessReadonly"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
@@ -59,6 +62,7 @@
:options="distributorOptions" :options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:disabled="businessReadonly" :disabled="businessReadonly"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/> />
<MalioCheckbox <MalioCheckbox
@@ -90,37 +94,44 @@
group-class="row-span-2 pt-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:disabled="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.competitors"
/> />
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.foundedAt"
/> />
<MalioInputText <MalioInputText
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.employeesCount"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.revenueAmount" v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:disabled="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.revenueAmount"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:readonly="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:disabled="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.profitAmount"
/> />
</div> </div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center"> <div v-if="!businessReadonly" class="mt-12 flex justify-center">
@@ -143,6 +154,7 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1" :removable="contacts.length > 1"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
@@ -179,6 +191,7 @@
:country-options="countryOptions" :country-options="countryOptions"
:removable="addresses.length > 1" :removable="addresses.length > 1"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@degraded="onAddressDegraded" @degraded="onAddressDegraded"
@@ -212,11 +225,13 @@
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="accountingErrors.errors.accountNumber"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
@@ -224,12 +239,14 @@
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:disabled="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="accountingErrors.errors.nTva"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
@@ -237,6 +254,7 @@
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
@@ -245,6 +263,7 @@
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:disabled="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange" @update:model-value="onPaymentTypeChange"
/> />
<MalioSelect <MalioSelect
@@ -254,6 +273,7 @@
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:disabled="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/> />
</div> </div>
@@ -278,16 +298,19 @@
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="ribErrors[index]?.iban"
/> />
</div> </div>
</div> </div>
@@ -343,7 +366,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, type Ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { import {
@@ -388,7 +411,7 @@ import {
type ContactFormDraft, type ContactFormDraft,
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } 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). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -578,6 +601,36 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
}) })
} }
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Un `useFormErrors` par groupe scalaire (submit independant) + un tableau
// d'erreurs par ligne pour chaque collection (aligne sur l'index visible).
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 exploitable → 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
}
showError(error)
return false
}
// ── Bloc principal ─────────────────────────────────────────────────────────── // ── Bloc principal ───────────────────────────────────────────────────────────
const isMainValid = computed(() => { const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== '' 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> { async function submitMain(): Promise<void> {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true mainSubmitting.value = true
mainErrors.clearErrors()
try { try {
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), { const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
@@ -615,7 +669,17 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { 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 { finally {
mainSubmitting.value = false mainSubmitting.value = false
@@ -627,12 +691,13 @@ async function submitMain(): Promise<void> {
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
informationErrors.clearErrors()
try { try {
await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false }) await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false })
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
showError(e) informationErrors.handleApiError(e, { fallbackMessage: t('commercial.clients.toast.error') })
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
@@ -656,6 +721,7 @@ function askRemoveContact(index: number): void {
const removed = contacts.value[index] const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id) if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1) contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation). // Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact()) if (contacts.value.length === 0) contacts.value.push(emptyContact())
}) })
@@ -669,26 +735,35 @@ function askRemoveContact(index: number): void {
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = []
try { try {
for (const id of removedContactIds.value) { for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false }) await api.delete(`/client_contacts/${id}`, {}, { toast: false })
} }
removedContactIds.value = [] 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 if (!isContactNamed(contact)) continue
const body = buildContactPayload(contact) const body = buildContactPayload(contact)
if (contact.id === null) { try {
const created = await api.post<{ '@id'?: string, id: number }>( if (contact.id === null) {
`/clients/${clientId}/contacts`, const created = await api.post<{ '@id'?: string, id: number }>(
body, `/clients/${clientId}/contacts`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
contact.id = created.id )
contact.iri = created['@id'] ?? null contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
} }
else { catch (error) {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false }) // 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe.
mapRowError(error, contactErrors, index)
return
} }
} }
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
@@ -721,6 +796,7 @@ function askRemoveAddress(index: number): void {
const removed = addresses.value[index] const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id) if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1) addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation). // Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress()) if (addresses.value.length === 0) addresses.value.push(emptyAddress())
}) })
@@ -739,24 +815,32 @@ function onAddressDegraded(): void {
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = []
try { try {
for (const id of removedAddressIds.value) { for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false }) await api.delete(`/client_addresses/${id}`, {}, { toast: false })
} }
removedAddressIds.value = [] 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)) const body = buildAddressPayload(address, isBillingEmailRequired(address))
if (address.id === null) { try {
const created = await api.post<{ id: number }>( if (address.id === null) {
`/clients/${clientId}/addresses`, const created = await api.post<{ id: number }>(
body, `/clients/${clientId}/addresses`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
address.id = created.id )
address.id = created.id
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
} }
else { catch (error) {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false }) mapRowError(error, addressErrors, index)
return
} }
} }
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
@@ -801,6 +885,7 @@ function askRemoveRib(index: number): void {
const removed = ribs.value[index] const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id) if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1) ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation). // Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib()) if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
@@ -815,27 +900,44 @@ function askRemoveRib(index: number): void {
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors()
ribErrors.value = []
try { 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) { for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false }) await api.delete(`/client_ribs/${id}`, {}, { toast: false })
} }
removedRibIds.value = [] 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 if (!ribIsComplete(rib)) continue
const body = buildRibPayload(rib) const body = buildRibPayload(rib)
if (rib.id === null) { try {
const created = await api.post<{ id: number }>( if (rib.id === null) {
`/clients/${clientId}/ribs`, const created = await api.post<{ id: number }>(
body, `/clients/${clientId}/ribs`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
rib.id = created.id )
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
} }
else { catch (error) {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false }) mapRowError(error, ribErrors, index)
return
} }
} }
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
+149 -60
View File
@@ -22,6 +22,7 @@
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:readonly="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
@@ -29,6 +30,7 @@
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="mainLocked" :disabled="mainLocked"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/> />
<MalioSelect <MalioSelect
@@ -45,6 +47,7 @@
:options="referentials.brokers.value" :options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:disabled="mainLocked" :disabled="mainLocked"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
@@ -53,6 +56,7 @@
:options="referentials.distributors.value" :options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:disabled="mainLocked" :disabled="mainLocked"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/> />
<MalioCheckbox <MalioCheckbox
@@ -86,37 +90,44 @@
group-class="row-span-2 pt-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:disabled="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.competitors"
/> />
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.foundedAt"
/> />
<MalioInputText <MalioInputText
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.revenueAmount" v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:disabled="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:disabled="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/> />
</div> </div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center"> <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 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="index > 0" :removable="index > 0"
:readonly="isValidated('contact')" :readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
@@ -178,6 +190,7 @@
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="index > 0"
:readonly="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@degraded="onAddressDegraded" @degraded="onAddressDegraded"
@@ -210,11 +223,13 @@
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="accountingErrors.errors.accountNumber"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
@@ -222,12 +237,14 @@
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:disabled="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="accountingErrors.errors.nTva"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
@@ -235,6 +252,7 @@
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
@@ -243,6 +261,7 @@
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:disabled="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange" @update:model-value="onPaymentTypeChange"
/> />
<MalioSelect <MalioSelect
@@ -252,6 +271,7 @@
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:disabled="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/> />
</div> </div>
@@ -277,16 +297,19 @@
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:error="ribErrors[index]?.iban"
/> />
</div> </div>
</div> </div>
@@ -340,7 +363,7 @@
</template> </template>
<script setup lang="ts"> <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 { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { import {
buildClientFormTabKeys, buildClientFormTabKeys,
@@ -359,7 +382,7 @@ import {
type ContactFormDraft, type ContactFormDraft,
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } 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). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -391,6 +414,38 @@ function apiErrorMessage(error: unknown): string {
return extractApiErrorMessage(data) || t('commercial.clients.toast.error') 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') }) useHead({ title: t('commercial.clients.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting // 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> { async function submitMain(): Promise<void> {
if (!isMainValid.value || mainSubmitting.value) return if (!isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true mainSubmitting.value = true
mainErrors.clearErrors()
try { try {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
companyName: main.companyName, companyName: main.companyName,
@@ -485,15 +541,18 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.createSuccess') }) toast.success({ title: t('commercial.clients.toast.createSuccess') })
} }
catch (error) { catch (error) {
// 409 = doublon nom de societe (RG d'unicite) → message explicite ; // 409 = doublon nom de societe (RG d'unicite) → erreur inline sur le
// sinon on remonte le message de validation du serveur (ex: 422). // 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 const status = (error as { response?: { status?: number } })?.response?.status
toast.error({ if (status === 409) {
title: t('commercial.clients.toast.error'), const message = t('commercial.clients.form.duplicateCompany')
message: status === 409 mainErrors.setError('companyName', message)
? t('commercial.clients.form.duplicateCompany') toast.error({ title: t('commercial.clients.toast.error'), message })
: apiErrorMessage(error), }
}) else {
mainErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
}
} }
finally { finally {
mainSubmitting.value = false mainSubmitting.value = false
@@ -568,6 +627,7 @@ const information = reactive({
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
informationErrors.clearErrors()
try { try {
await api.patch(`/clients/${clientId.value}`, { await api.patch(`/clients/${clientId.value}`, {
description: information.description || null, description: information.description || null,
@@ -582,7 +642,7 @@ async function submitInformation(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) { catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }) informationErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
@@ -610,6 +670,7 @@ function addContact(): void {
function askRemoveContact(index: number): void { function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => { askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
contacts.value.splice(index, 1) contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
}) })
} }
@@ -617,8 +678,10 @@ function askRemoveContact(index: number): void {
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = []
try { 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). // On ignore les blocs totalement vides (ni nom ni prenom).
if (!isContactNamed(contact)) continue if (!isContactNamed(contact)) continue
@@ -631,25 +694,30 @@ async function submitContacts(): Promise<void> {
email: contact.email || null, email: contact.email || null,
} }
if (contact.id === null) { try {
const created = await api.post<ContactResponse>( if (contact.id === null) {
`/clients/${clientId.value}/contacts`, const created = await api.post<ContactResponse>(
body, `/clients/${clientId.value}/contacts`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
contact.id = created.id )
contact.iri = created['@id'] ?? null contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
} }
else { catch (error) {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false }) // 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') completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
} }
@@ -698,6 +766,7 @@ function addAddress(): void {
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => { askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
addresses.value.splice(index, 1) addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
}) })
} }
@@ -715,8 +784,10 @@ function onAddressDegraded(): void {
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = []
try { try {
for (const address of addresses.value) { for (let index = 0; index < addresses.value.length; index++) {
const address = addresses.value[index]
const body = { const body = {
isProspect: address.isProspect, isProspect: address.isProspect,
isDelivery: address.isDelivery, isDelivery: address.isDelivery,
@@ -732,24 +803,27 @@ async function submitAddresses(): Promise<void> {
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null, billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
} }
if (address.id === null) { try {
const created = await api.post<{ id: number }>( if (address.id === null) {
`/clients/${clientId.value}/addresses`, const created = await api.post<{ id: number }>(
body, `/clients/${clientId.value}/addresses`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
address.id = created.id )
address.id = created.id
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
} }
else { catch (error) {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false }) mapRowError(error, addressErrors, index)
return
} }
} }
completeTab('address') completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
} }
@@ -802,6 +876,7 @@ function addRib(): void {
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => { askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
ribs.value.splice(index, 1) ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce au montage). // Garde au moins un bloc RIB visible (cf. amorce au montage).
if (ribs.value.length === 0) ribs.value.push(emptyRib()) if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
@@ -815,38 +890,52 @@ function askRemoveRib(index: number): void {
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors()
ribErrors.value = []
try { try {
await api.patch(`/clients/${clientId.value}`, { // 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
siren: accounting.siren || null, try {
accountNumber: accounting.accountNumber || null, await api.patch(`/clients/${clientId.value}`, {
tvaMode: accounting.tvaModeIri, siren: accounting.siren || null,
nTva: accounting.nTva || null, accountNumber: accounting.accountNumber || null,
paymentDelay: accounting.paymentDelayIri, tvaMode: accounting.tvaModeIri,
paymentType: accounting.paymentTypeIri, nTva: accounting.nTva || null,
bank: isBankRequired.value ? accounting.bankIri : null, paymentDelay: accounting.paymentDelayIri,
}, { toast: false }) 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 (!ribIsComplete(rib)) continue
if (rib.id === null) { try {
const created = await api.post<{ id: number }>( if (rib.id === null) {
`/clients/${clientId.value}/ribs`, const created = await api.post<{ id: number }>(
{ label: rib.label, bic: rib.bic, iban: rib.iban }, `/clients/${clientId.value}/ribs`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, { label: rib.label, bic: rib.bic, iban: rib.iban },
) { headers: { Accept: 'application/ld+json' }, toast: false },
rib.id = created.id )
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
}
} }
else { catch (error) {
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false }) mapRowError(error, ribErrors, index)
return
} }
} }
completeTab('accounting') completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
} }
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFormErrors } from '../useFormErrors'
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
/**
* Tests du composable `useFormErrors` — pendant front de la regle « le back
* renvoie toutes les violations 422 d'un coup » (ERP-101). Centralise l'etat
* d'erreurs par champ (`Record<propertyPath, message>`) et la dispatch d'une
* erreur API : 422 mappee inline, sinon toast de fallback.
*/
describe('useFormErrors', () => {
beforeEach(() => {
mockToastError.mockReset()
})
/** Fabrique une erreur ofetch avec status + payload. */
function fetchError(status: number, data: unknown) {
return { response: { status, _data: data } }
}
it('demarre sans erreur', () => {
const { errors, hasErrors } = useFormErrors()
expect(errors).toEqual({})
expect(hasErrors.value).toBe(false)
})
it('setServerErrors mappe les violations par champ et retourne true', () => {
const { errors, hasErrors, setServerErrors } = useFormErrors()
const mapped = setServerErrors({
violations: [
{ propertyPath: 'companyName', message: 'Obligatoire.' },
{ propertyPath: 'siren', message: 'Deja utilise.' },
],
})
expect(mapped).toBe(true)
expect(errors).toEqual({ companyName: 'Obligatoire.', siren: 'Deja utilise.' })
expect(hasErrors.value).toBe(true)
})
it('setServerErrors retourne false et ne touche rien sans violation', () => {
const { errors, setServerErrors } = useFormErrors()
expect(setServerErrors({})).toBe(false)
expect(errors).toEqual({})
})
it('setError / clearError / clearErrors manipulent l\'etat finement', () => {
const { errors, setError, clearError, clearErrors } = useFormErrors()
setError('iban', 'IBAN invalide.')
expect(errors.iban).toBe('IBAN invalide.')
clearError('iban')
expect(errors.iban).toBeUndefined()
setError('a', 'x')
setError('b', 'y')
clearErrors()
expect(errors).toEqual({})
})
it('handleApiError : 422 avec violations → mappe inline, pas de toast, retourne true', () => {
const { errors, handleApiError } = useFormErrors()
const handled = handleApiError(
fetchError(422, { violations: [{ propertyPath: 'email', message: 'Invalide.' }] }),
)
expect(handled).toBe(true)
expect(errors.email).toBe('Invalide.')
expect(mockToastError).not.toHaveBeenCalled()
})
it('handleApiError : erreur non-422 → toast de fallback, retourne false', () => {
const { errors, handleApiError } = useFormErrors()
const handled = handleApiError(
fetchError(500, { 'hydra:description': 'Erreur serveur.' }),
{ fallbackMessage: 'Oups.' },
)
expect(handled).toBe(false)
expect(errors).toEqual({})
expect(mockToastError).toHaveBeenCalledTimes(1)
expect(mockToastError.mock.calls[0][0]).toMatchObject({ message: 'Erreur serveur.' })
})
it('handleApiError : 422 sans violation mappable → toast de fallback, retourne false', () => {
const { handleApiError } = useFormErrors()
const handled = handleApiError(fetchError(422, { 'hydra:description': 'Donnees invalides.' }))
expect(handled).toBe(false)
expect(mockToastError).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,112 @@
/**
* Composable d'erreurs de formulaire — convention de mapping erreur→champ pour
* tous les forms du projet (ERP-101).
*
* Le back renvoie TOUTES les violations d'une 422 d'un coup (un `propertyPath`
* + `message` par champ fautif). Ce composable centralise leur affichage
* inline : il tient un `Record<propertyPath, message>` reactif que le template
* branche directement sur la prop `:error` des composants `Malio*` (le nom du
* champ cote front = le `propertyPath` cote back, donc aucun mapping manuel).
*
* Chaque appel cree son propre etat (refs internes a la fonction) — un form =
* une instance, pas de singleton partage.
*
* Convention d'usage : les appels API qui veulent un retour inline doivent
* passer `{ toast: false }` a `useApi` (sinon le toast natif masque le mapping
* fin), puis router l'erreur via `handleApiError`. Pour les collections (1
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
*/
import { computed, reactive } from 'vue'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
/**
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
* + payload) pour eviter de typer toute la lib.
*/
interface ApiFetchError {
response?: {
status?: number
_data?: unknown
}
}
/** Options de `handleApiError`. */
interface HandleApiErrorOptions {
/** Message de toast si l'erreur n'est pas une 422 exploitable. */
fallbackMessage?: string
}
export function useFormErrors() {
const toast = useToast()
// Etat d'erreurs indexe par propertyPath. Reactif : muter une cle suffit a
// rafraichir la prop `:error` du champ correspondant.
const errors = reactive<Record<string, string>>({})
const hasErrors = computed(() => Object.keys(errors).length > 0)
/** Pose une erreur sur un champ. */
function setError(field: string, message: string): void {
errors[field] = message
}
/** Retire l'erreur d'un champ (no-op si absente). */
function clearError(field: string): void {
delete errors[field]
}
/** Vide toutes les erreurs (a appeler en debut de submit). */
function clearErrors(): void {
for (const key of Object.keys(errors)) {
delete errors[key]
}
}
/**
* Mappe les violations 422 d'un payload sur les champs. Retourne true des
* qu'au moins une violation a ete posee, false sinon (payload sans
* violation exploitable).
*/
function setServerErrors(data: unknown): boolean {
const mapped = mapViolationsToRecord(data)
const keys = Object.keys(mapped)
if (keys.length === 0) return false
for (const key of keys) {
errors[key] = mapped[key]
}
return true
}
/**
* Route une erreur API : 422 avec violations exploitables → mapping inline
* (pas de toast, l'erreur s'affiche sous le champ) ; sinon → toast de
* fallback (message serveur extrait, ou `fallbackMessage`).
*
* Retourne true si l'erreur a ete mappee inline, false si fallback toast.
*/
function handleApiError(e: unknown, opts: HandleApiErrorOptions = {}): boolean {
const status = (e as ApiFetchError)?.response?.status
const data = (e as ApiFetchError)?.response?._data
if (status === 422 && setServerErrors(data)) {
return true
}
const message
= extractApiErrorMessage(data)
|| opts.fallbackMessage
|| 'Une erreur est survenue.'
toast.error({ title: 'Erreur', message })
return false
}
return {
errors,
hasErrors,
setError,
clearError,
clearErrors,
setServerErrors,
handleApiError,
}
}
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { mapViolationsToRecord } from '../api'
/**
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
* formulaires (ERP-101). Transforme un payload 422 API Platform en
* `Record<propertyPath, message>` directement consommable par la prop `:error`
* des composants `Malio*`.
*/
describe('mapViolationsToRecord', () => {
it('mappe chaque violation par son propertyPath (format `violations`)', () => {
const data = {
violations: [
{ propertyPath: 'companyName', message: 'Obligatoire.' },
{ propertyPath: 'siren', message: 'SIREN deja utilise.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({
companyName: 'Obligatoire.',
siren: 'SIREN deja utilise.',
})
})
it('supporte le format negocie `hydra:violations`', () => {
const data = {
'hydra:violations': [
{ propertyPath: 'email', message: 'Adresse invalide.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({ email: 'Adresse invalide.' })
})
it('renvoie un objet vide quand il n\'y a pas de violation exploitable', () => {
expect(mapViolationsToRecord({})).toEqual({})
expect(mapViolationsToRecord(null)).toEqual({})
expect(mapViolationsToRecord({ violations: [] })).toEqual({})
})
it('ignore les violations sans propertyPath', () => {
const data = {
violations: [
{ propertyPath: '', message: 'Erreur globale.' },
{ propertyPath: 'iban', message: 'IBAN invalide.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({ iban: 'IBAN invalide.' })
})
it('en cas de doublon de propertyPath, la derniere violation gagne', () => {
const data = {
violations: [
{ propertyPath: 'name', message: 'Premier message.' },
{ propertyPath: 'name', message: 'Second message.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
})
})
+19
View File
@@ -66,6 +66,25 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
return out return out
} }
/**
* Transforme un payload d'erreur 422 d'API Platform en dictionnaire
* `{ propertyPath: message }`, directement consommable par la prop `:error`
* des composants `Malio*` (le nom du champ cote front = le `propertyPath`
* renvoye par le back). Fondation du mapping erreur→champ des formulaires :
* utilise par `useFormErrors` (champs scalaires) et par les boucles de submit
* de collections (erreur par ligne).
*
* Les violations sans `propertyPath` (erreur globale) sont ignorees ; en cas
* de doublon de `propertyPath`, la derniere violation l'emporte.
*/
export function mapViolationsToRecord(data: unknown): Record<string, string> {
const out: Record<string, string> = {}
for (const v of extractApiViolations(data)) {
if (v.propertyPath) out[v.propertyPath] = v.message
}
return out
}
/** /**
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON * Extrait un message d'erreur lisible depuis un payload Hydra / JSON
* d'erreur API Platform. Essaie les champs courants dans l'ordre : * d'erreur API Platform. Essaie les champs courants dans l'ordre :