# 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**.
