Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90dfc17fcb | |||
| ce89c5e46a | |||
| 546ba462b9 | |||
| ee3bbea649 |
@@ -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` :
|
||||||
|
|||||||
+22
-19
@@ -38,6 +38,28 @@ declare(strict_types=1);
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
// Section "Commerciale" : pole metier principal, remontee en tete de sidebar (ERP-71).
|
||||||
|
// L'ordre interne des onglets et les permissions restent inchanges (simple deplacement
|
||||||
|
// du bloc, aucun gate touche).
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.commercial.section',
|
||||||
|
'icon' => 'mdi:account-arrow-left-outline',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.commercial.clients',
|
||||||
|
'to' => '/clients',
|
||||||
|
'icon' => 'mdi:account-group-outline',
|
||||||
|
'module' => 'commercial',
|
||||||
|
'permission' => 'commercial.clients.view',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.commercial.suppliers',
|
||||||
|
'to' => '/suppliers',
|
||||||
|
'icon' => 'mdi:account-arrow-left-outline',
|
||||||
|
'module' => 'commercial',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
// Section "Administration" : regroupe toutes les pages de configuration
|
// Section "Administration" : regroupe toutes les pages de configuration
|
||||||
// applicative (RBAC, users, sites, audit log).
|
// applicative (RBAC, users, sites, audit log).
|
||||||
//
|
//
|
||||||
@@ -99,25 +121,6 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'label' => 'sidebar.commercial.section',
|
|
||||||
'icon' => 'mdi:account-arrow-left-outline',
|
|
||||||
'items' => [
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.commercial.clients',
|
|
||||||
'to' => '/clients',
|
|
||||||
'icon' => 'mdi:account-group-outline',
|
|
||||||
'module' => 'commercial',
|
|
||||||
'permission' => 'commercial.clients.view',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.commercial.suppliers',
|
|
||||||
'to' => '/suppliers',
|
|
||||||
'icon' => 'mdi:account-arrow-left-outline',
|
|
||||||
'module' => 'commercial',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
// Section "Mon compte" : espace personnel. Accessible a tout user authentifie
|
// Section "Mon compte" : espace personnel. Accessible a tout user authentifie
|
||||||
// (aucune permission RBAC requise, tous les items restent dans `core` pour
|
// (aucune permission RBAC requise, tous les items restent dans `core` pour
|
||||||
// rester toujours presents meme quand les modules metier sont desactives).
|
// rester toujours presents meme quand les modules metier sont desactives).
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.78'
|
app.version: '0.1.80'
|
||||||
|
|||||||
@@ -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**.
|
||||||
@@ -228,7 +228,10 @@
|
|||||||
},
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
|
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
|
||||||
}
|
},
|
||||||
|
"title": "Erreur",
|
||||||
|
"generic": "Une erreur est survenue.",
|
||||||
|
"unknown": "Erreur inconnue."
|
||||||
},
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
"selector": {
|
"selector": {
|
||||||
@@ -285,7 +288,8 @@
|
|||||||
"success": {
|
"success": {
|
||||||
"auth": {
|
"auth": {
|
||||||
"logout": "Deconnexion reussie"
|
"logout": "Deconnexion reussie"
|
||||||
}
|
},
|
||||||
|
"title": "Succès"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"roles": {
|
"roles": {
|
||||||
|
|||||||
@@ -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({})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -213,7 +220,7 @@ describe('useCategoryForm', () => {
|
|||||||
await form.submitCreate()
|
await form.submitCreate()
|
||||||
|
|
||||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||||
title: 'Succès',
|
title: 'success.title',
|
||||||
message: 'admin.categories.toast.created',
|
message: 'admin.categories.toast.created',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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,9 +299,10 @@ 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: 'errors.title',
|
||||||
message: 'Boom server',
|
message: 'Boom server',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -370,7 +378,7 @@ describe('useCategoryForm', () => {
|
|||||||
await form.submitUpdate(42)
|
await form.submitUpdate(42)
|
||||||
|
|
||||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||||
title: 'Succès',
|
title: 'success.title',
|
||||||
message: 'admin.categories.toast.updated',
|
message: 'admin.categories.toast.updated',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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"')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -401,7 +409,7 @@ describe('useCategoryForm', () => {
|
|||||||
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
|
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
|
||||||
expect(ok).toBe(true)
|
expect(ok).toBe(true)
|
||||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||||
title: 'Succès',
|
title: 'success.title',
|
||||||
message: 'admin.categories.toast.deleted',
|
message: 'admin.categories.toast.deleted',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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: t('errors.title'), message: duplicateMessage })
|
||||||
title: 'Erreur',
|
|
||||||
message: duplicateMessage,
|
|
||||||
})
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 422 && mapServerViolations(data)) {
|
return formErrors.handleApiError(e, { fallbackMessage: t('errors.generic') })
|
||||||
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,14 +144,13 @@ 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, {
|
||||||
toast: false,
|
toast: false,
|
||||||
})
|
})
|
||||||
toast.success({
|
toast.success({
|
||||||
title: 'Succès',
|
title: t('success.title'),
|
||||||
message: t('admin.categories.toast.created'),
|
message: t('admin.categories.toast.created'),
|
||||||
})
|
})
|
||||||
return created
|
return created
|
||||||
@@ -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()
|
||||||
@@ -250,7 +189,7 @@ export function useCategoryForm() {
|
|||||||
toast: false,
|
toast: false,
|
||||||
})
|
})
|
||||||
toast.success({
|
toast.success({
|
||||||
title: 'Succès',
|
title: t('success.title'),
|
||||||
message: t('admin.categories.toast.updated'),
|
message: t('admin.categories.toast.updated'),
|
||||||
})
|
})
|
||||||
return updated
|
return updated
|
||||||
@@ -272,11 +211,11 @@ 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({
|
||||||
title: 'Succès',
|
title: t('success.title'),
|
||||||
message: t('admin.categories.toast.deleted'),
|
message: t('admin.categories.toast.deleted'),
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
:options="categoryOptions"
|
:options="categoryOptions"
|
||||||
:label="t('commercial.clients.form.address.categories')"
|
:label="t('commercial.clients.form.address.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:disabled="readonly"
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -52,7 +53,8 @@
|
|||||||
:model-value="model.country"
|
:model-value="model.country"
|
||||||
:options="countryOptions"
|
:options="countryOptions"
|
||||||
:label="t('commercial.clients.form.address.country')"
|
:label="t('commercial.clients.form.address.country')"
|
||||||
:disabled="readonly"
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -61,6 +63,8 @@
|
|||||||
: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"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.postalCode"
|
||||||
@update:model-value="onPostalCodeChange"
|
@update:model-value="onPostalCodeChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -71,8 +75,10 @@
|
|||||||
:model-value="model.city"
|
:model-value="model.city"
|
||||||
:options="cityOptions"
|
:options="cityOptions"
|
||||||
:label="t('commercial.clients.form.address.city')"
|
:label="t('commercial.clients.form.address.city')"
|
||||||
:disabled="readonly"
|
:readonly="readonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
: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 +86,8 @@
|
|||||||
: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"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.city"
|
||||||
@update:model-value="(v: string) => update('city', v)"
|
@update:model-value="(v: string) => update('city', v)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -99,6 +107,8 @@
|
|||||||
: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"
|
||||||
|
:required="true"
|
||||||
|
: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 +118,8 @@
|
|||||||
: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"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.street"
|
||||||
@update:model-value="(v: string) => update('street', v)"
|
@update:model-value="(v: string) => update('street', v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,6 +129,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>
|
||||||
@@ -139,7 +152,7 @@
|
|||||||
:options="contactOptions"
|
:options="contactOptions"
|
||||||
:label="t('commercial.clients.form.address.contacts')"
|
:label="t('commercial.clients.form.address.contacts')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:disabled="readonly"
|
:readonly="readonly"
|
||||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -151,6 +164,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 +197,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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||||
|
import { useClientFormErrors } from '../useClientFormErrors'
|
||||||
|
|
||||||
|
// useFormErrors (auto-import) expose l'implementation reelle ; elle consomme
|
||||||
|
// useToast + useI18n, stubbes ici.
|
||||||
|
vi.stubGlobal('useToast', () => ({ error: vi.fn(), success: vi.fn() }))
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useFormErrors', useFormErrors)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du composable partage `useClientFormErrors` — factorisation du cablage
|
||||||
|
* d'erreurs des ecrans client (creation/edition), suggestion de revue ERP-101.
|
||||||
|
* `mapRowError` ne toaste plus : il retourne un booleen et chaque page garde son
|
||||||
|
* propre fallback (toast.error en creation, showError en edition).
|
||||||
|
*/
|
||||||
|
describe('useClientFormErrors', () => {
|
||||||
|
it('expose les 3 etats scalaires (vides) et les 3 tableaux d\'erreurs par ligne', () => {
|
||||||
|
const f = useClientFormErrors()
|
||||||
|
expect(f.mainErrors.errors).toEqual({})
|
||||||
|
expect(f.informationErrors.errors).toEqual({})
|
||||||
|
expect(f.accountingErrors.errors).toEqual({})
|
||||||
|
expect(f.contactErrors.value).toEqual([])
|
||||||
|
expect(f.addressErrors.value).toEqual([])
|
||||||
|
expect(f.ribErrors.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mapRowError mappe une 422 sur target[index] et retourne true', () => {
|
||||||
|
const f = useClientFormErrors()
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'email', message: 'Adresse invalide.' }] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const mapped = f.mapRowError(error, f.contactErrors, 0)
|
||||||
|
expect(mapped).toBe(true)
|
||||||
|
expect(f.contactErrors.value[0]).toEqual({ email: 'Adresse invalide.' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mapRowError retourne false et ne touche pas la cible pour une erreur non-422', () => {
|
||||||
|
const f = useClientFormErrors()
|
||||||
|
const error = { response: { status: 500, _data: {} } }
|
||||||
|
expect(f.mapRowError(error, f.ribErrors, 0)).toBe(false)
|
||||||
|
expect(f.ribErrors.value[0]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mapRowError retourne false pour une 422 sans violation exploitable', () => {
|
||||||
|
const f = useClientFormErrors()
|
||||||
|
const error = { response: { status: 422, _data: { 'hydra:description': 'Donnees invalides.' } } }
|
||||||
|
expect(f.mapRowError(error, f.addressErrors, 0)).toBe(false)
|
||||||
|
expect(f.addressErrors.value[0]).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Composable d'erreurs partage des ecrans client (creation + edition, M1
|
||||||
|
* Commercial). Factorise le cablage identique entre `clients/new.vue` et
|
||||||
|
* `clients/[id]/edit.vue` (suggestion de revue ERP-101) :
|
||||||
|
* - un `useFormErrors` par groupe scalaire (Principal / Information /
|
||||||
|
* Comptabilite) : violations 422 affichees inline sous chaque champ ;
|
||||||
|
* - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts /
|
||||||
|
* adresses / RIB), aligne sur l'index du `v-for`.
|
||||||
|
*
|
||||||
|
* `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe
|
||||||
|
* inline). Chaque page conserve ainsi son propre fallback dans le `catch`
|
||||||
|
* (toast generique en creation, `showError` en edition) sans imposer un
|
||||||
|
* comportement commun.
|
||||||
|
*/
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
export function useClientFormErrors() {
|
||||||
|
const mainErrors = useFormErrors()
|
||||||
|
const informationErrors = useFormErrors()
|
||||||
|
const accountingErrors = useFormErrors()
|
||||||
|
const contactErrors = ref<Record<string, string>[]>([])
|
||||||
|
const addressErrors = ref<Record<string, string>[]>([])
|
||||||
|
const ribErrors = ref<Record<string, string>[]>([])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappe l'erreur d'une ligne de collection sur le tableau cible (par index).
|
||||||
|
* 422 avec violations exploitables → erreurs inline sous les champs de la
|
||||||
|
* ligne + retourne true. Sinon → ne touche pas la cible et retourne false
|
||||||
|
* (le caller decide du fallback toast).
|
||||||
|
*/
|
||||||
|
function mapRowError(
|
||||||
|
error: unknown,
|
||||||
|
target: Ref<Record<string, string>[]>,
|
||||||
|
index: number,
|
||||||
|
): boolean {
|
||||||
|
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
|
||||||
|
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
|
||||||
|
if (Object.keys(mapped).length > 0) {
|
||||||
|
target.value[index] = mapped
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mainErrors,
|
||||||
|
informationErrors,
|
||||||
|
accountingErrors,
|
||||||
|
contactErrors,
|
||||||
|
addressErrors,
|
||||||
|
ribErrors,
|
||||||
|
mapRowError,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,13 +28,16 @@
|
|||||||
:label="t('commercial.clients.form.main.companyName')"
|
: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"
|
||||||
:options="mainCategoryOptions"
|
:options="mainCategoryOptions"
|
||||||
:label="t('commercial.clients.form.main.categories')"
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:disabled="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
|
:required="true"
|
||||||
|
: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
|
||||||
@@ -42,7 +45,7 @@
|
|||||||
:options="relationOptions"
|
:options="relationOptions"
|
||||||
:label="t('commercial.clients.form.main.relation')"
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||||
:disabled="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
@update:model-value="onRelationChange"
|
@update:model-value="onRelationChange"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -50,7 +53,9 @@
|
|||||||
:model-value="main.brokerIri"
|
:model-value="main.brokerIri"
|
||||||
:options="brokerOptions"
|
:options="brokerOptions"
|
||||||
:label="t('commercial.clients.form.main.brokerName')"
|
:label="t('commercial.clients.form.main.brokerName')"
|
||||||
:disabled="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="mainErrors.errors.broker"
|
||||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -58,7 +63,9 @@
|
|||||||
:model-value="main.distributorIri"
|
:model-value="main.distributorIri"
|
||||||
:options="distributorOptions"
|
:options="distributorOptions"
|
||||||
:label="t('commercial.clients.form.main.distributorName')"
|
:label="t('commercial.clients.form.main.distributorName')"
|
||||||
:disabled="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="mainErrors.errors.distributor"
|
||||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||||
/>
|
/>
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
@@ -79,7 +86,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
|
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||||
<!-- Onglet Information -->
|
<!-- Onglet Information -->
|
||||||
<template #information>
|
<template #information>
|
||||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
@@ -89,38 +96,45 @@
|
|||||||
resize="none"
|
resize="none"
|
||||||
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"
|
:readonly="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"
|
:readonly="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"
|
:readonly="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 +157,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 +194,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,39 +228,51 @@
|
|||||||
:label="t('commercial.clients.form.accounting.siren')"
|
:label="t('commercial.clients.form.accounting.siren')"
|
||||||
:mask="SIREN_MASK"
|
:mask="SIREN_MASK"
|
||||||
:readonly="accountingReadonly"
|
:readonly="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
: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"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.accountNumber"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="accounting.tvaModeIri"
|
:model-value="accounting.tvaModeIri"
|
||||||
:options="tvaModeOptions"
|
:options="tvaModeOptions"
|
||||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||||
:disabled="accountingReadonly"
|
:readonly="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
: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"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.nTva"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="accounting.paymentDelayIri"
|
:model-value="accounting.paymentDelayIri"
|
||||||
:options="paymentDelayOptions"
|
:options="paymentDelayOptions"
|
||||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||||
:disabled="accountingReadonly"
|
:readonly="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
: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
|
||||||
:model-value="accounting.paymentTypeIri"
|
:model-value="accounting.paymentTypeIri"
|
||||||
:options="paymentTypeOptions"
|
:options="paymentTypeOptions"
|
||||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||||
:disabled="accountingReadonly"
|
:readonly="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.paymentType"
|
||||||
@update:model-value="onPaymentTypeChange"
|
@update:model-value="onPaymentTypeChange"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -252,8 +280,10 @@
|
|||||||
:model-value="accounting.bankIri"
|
:model-value="accounting.bankIri"
|
||||||
:options="bankOptions"
|
:options="bankOptions"
|
||||||
:label="t('commercial.clients.form.accounting.bank')"
|
:label="t('commercial.clients.form.accounting.bank')"
|
||||||
:disabled="accountingReadonly"
|
:readonly="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
: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 +308,22 @@
|
|||||||
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"
|
||||||
|
:required="isRibRequired"
|
||||||
|
: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"
|
||||||
|
:required="isRibRequired"
|
||||||
|
: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"
|
||||||
|
:required="isRibRequired"
|
||||||
|
:error="ribErrors[index]?.iban"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,6 +382,7 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, 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 { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
|
||||||
import {
|
import {
|
||||||
canEditClient,
|
canEditClient,
|
||||||
categoryOptionsOf,
|
categoryOptionsOf,
|
||||||
@@ -578,6 +615,22 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
|
||||||
|
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
|
||||||
|
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
|
||||||
|
// chaque collection (aligne sur l'index visible). `mapRowError` mappe une 422
|
||||||
|
// inline et retourne true ; il ne toaste pas, le fallback `showError` reste
|
||||||
|
// local a l'edition (cf. catch des submits de collection).
|
||||||
|
const {
|
||||||
|
mainErrors,
|
||||||
|
informationErrors,
|
||||||
|
accountingErrors,
|
||||||
|
contactErrors,
|
||||||
|
addressErrors,
|
||||||
|
ribErrors,
|
||||||
|
mapRowError,
|
||||||
|
} = useClientFormErrors()
|
||||||
|
|
||||||
// ── Bloc principal ───────────────────────────────────────────────────────────
|
// ── 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,37 @@ 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.
|
||||||
|
if (!mapRowError(error, contactErrors, index)) {
|
||||||
|
showError(error)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
@@ -721,6 +798,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 +817,34 @@ 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 })
|
if (!mapRowError(error, addressErrors, index)) {
|
||||||
|
showError(error)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
@@ -801,6 +889,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 +904,46 @@ 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 })
|
if (!mapRowError(error, ribErrors, index)) {
|
||||||
|
showError(error)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
:options="mainCategoryOptions"
|
:options="mainCategoryOptions"
|
||||||
:label="t('commercial.clients.form.main.categories')"
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
disabled
|
readonly
|
||||||
/>
|
/>
|
||||||
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
|
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
:options="relationOptions"
|
:options="relationOptions"
|
||||||
:label="t('commercial.clients.form.main.relation')"
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||||
disabled
|
readonly
|
||||||
/>
|
/>
|
||||||
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
|
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
|
||||||
aucune valeur sans relation — meme comportement qu'en edition). -->
|
aucune valeur sans relation — meme comportement qu'en edition). -->
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||||
<!-- Onglet Information -->
|
<!-- Onglet Information -->
|
||||||
<template #information>
|
<template #information>
|
||||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
resize="none"
|
resize="none"
|
||||||
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
|
readonly
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="information.competitors"
|
:model-value="information.competitors"
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
:model-value="information.revenueAmount"
|
:model-value="information.revenueAmount"
|
||||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||||
disabled
|
readonly
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="information.directorName"
|
:model-value="information.directorName"
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
<MalioInputAmount
|
<MalioInputAmount
|
||||||
:model-value="information.profitAmount"
|
:model-value="information.profitAmount"
|
||||||
:label="t('commercial.clients.form.information.profitAmount')"
|
:label="t('commercial.clients.form.information.profitAmount')"
|
||||||
disabled
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
:options="tvaModeOptions"
|
:options="tvaModeOptions"
|
||||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
disabled
|
readonly
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="accounting.nTva"
|
:model-value="accounting.nTva"
|
||||||
@@ -192,14 +192,14 @@
|
|||||||
:options="paymentDelayOptions"
|
:options="paymentDelayOptions"
|
||||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
disabled
|
readonly
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="accounting.paymentTypeIri"
|
:model-value="accounting.paymentTypeIri"
|
||||||
:options="paymentTypeOptions"
|
:options="paymentTypeOptions"
|
||||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
disabled
|
readonly
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-if="accounting.bankIri"
|
v-if="accounting.bankIri"
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
:options="bankOptions"
|
:options="bankOptions"
|
||||||
:label="t('commercial.clients.form.accounting.bank')"
|
:label="t('commercial.clients.form.accounting.bank')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
disabled
|
readonly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,13 +22,16 @@
|
|||||||
: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"
|
||||||
:options="referentials.categories.value"
|
:options="referentials.categories.value"
|
||||||
:label="t('commercial.clients.form.main.categories')"
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
:display-tag="true"
|
:display-tag="true"
|
||||||
:disabled="mainLocked"
|
:readonly="mainLocked"
|
||||||
|
:required="true"
|
||||||
|
: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
|
||||||
@@ -36,7 +39,7 @@
|
|||||||
:options="relationOptions"
|
:options="relationOptions"
|
||||||
:label="t('commercial.clients.form.main.relation')"
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||||
:disabled="mainLocked"
|
:readonly="mainLocked"
|
||||||
@update:model-value="onRelationChange"
|
@update:model-value="onRelationChange"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -44,7 +47,9 @@
|
|||||||
:model-value="main.brokerIri"
|
:model-value="main.brokerIri"
|
||||||
:options="referentials.brokers.value"
|
:options="referentials.brokers.value"
|
||||||
:label="t('commercial.clients.form.main.brokerName')"
|
:label="t('commercial.clients.form.main.brokerName')"
|
||||||
:disabled="mainLocked"
|
:readonly="mainLocked"
|
||||||
|
:required="true"
|
||||||
|
:error="mainErrors.errors.broker"
|
||||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -52,7 +57,9 @@
|
|||||||
:model-value="main.distributorIri"
|
:model-value="main.distributorIri"
|
||||||
:options="referentials.distributors.value"
|
:options="referentials.distributors.value"
|
||||||
:label="t('commercial.clients.form.main.distributorName')"
|
:label="t('commercial.clients.form.main.distributorName')"
|
||||||
:disabled="mainLocked"
|
:readonly="mainLocked"
|
||||||
|
:required="true"
|
||||||
|
:error="mainErrors.errors.distributor"
|
||||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||||
/>
|
/>
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
@@ -85,38 +92,45 @@
|
|||||||
resize="none"
|
resize="none"
|
||||||
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')"
|
:readonly="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')"
|
:readonly="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')"
|
:readonly="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 +156,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 +193,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,39 +226,51 @@
|
|||||||
:label="t('commercial.clients.form.accounting.siren')"
|
:label="t('commercial.clients.form.accounting.siren')"
|
||||||
:mask="SIREN_MASK"
|
:mask="SIREN_MASK"
|
||||||
:readonly="accountingReadonly"
|
:readonly="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
: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"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.accountNumber"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="accounting.tvaModeIri"
|
:model-value="accounting.tvaModeIri"
|
||||||
:options="referentials.tvaModes.value"
|
:options="referentials.tvaModes.value"
|
||||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||||
:disabled="accountingReadonly"
|
:readonly="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
: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"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.nTva"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="accounting.paymentDelayIri"
|
:model-value="accounting.paymentDelayIri"
|
||||||
:options="referentials.paymentDelays.value"
|
:options="referentials.paymentDelays.value"
|
||||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||||
:disabled="accountingReadonly"
|
:readonly="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
: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
|
||||||
:model-value="accounting.paymentTypeIri"
|
:model-value="accounting.paymentTypeIri"
|
||||||
:options="referentials.paymentTypes.value"
|
:options="referentials.paymentTypes.value"
|
||||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||||
:disabled="accountingReadonly"
|
:readonly="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.paymentType"
|
||||||
@update:model-value="onPaymentTypeChange"
|
@update:model-value="onPaymentTypeChange"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
@@ -250,8 +278,10 @@
|
|||||||
:model-value="accounting.bankIri"
|
:model-value="accounting.bankIri"
|
||||||
:options="referentials.banks.value"
|
:options="referentials.banks.value"
|
||||||
:label="t('commercial.clients.form.accounting.bank')"
|
:label="t('commercial.clients.form.accounting.bank')"
|
||||||
:disabled="accountingReadonly"
|
:readonly="accountingReadonly"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
: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 +307,22 @@
|
|||||||
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"
|
||||||
|
:required="isRibRequired"
|
||||||
|
: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"
|
||||||
|
:required="isRibRequired"
|
||||||
|
: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"
|
||||||
|
:required="isRibRequired"
|
||||||
|
:error="ribErrors[index]?.iban"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,6 +378,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
|
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
|
||||||
import {
|
import {
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
CLIENT_FORM_PLACEHOLDER_TABS,
|
CLIENT_FORM_PLACEHOLDER_TABS,
|
||||||
@@ -391,6 +428,22 @@ 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) ───────────────────────────────
|
||||||
|
// Etat d'erreurs factorise entre creation et edition (cf. useClientFormErrors) :
|
||||||
|
// un `useFormErrors` par groupe scalaire (Principal / Information / Comptabilite)
|
||||||
|
// + un tableau d'erreurs par ligne pour chaque collection (contacts/adresses/RIB).
|
||||||
|
// `mapRowError` mappe une 422 inline et retourne true ; il ne toaste pas, le
|
||||||
|
// fallback reste local a la creation (cf. catch des submits de collection).
|
||||||
|
const {
|
||||||
|
mainErrors,
|
||||||
|
informationErrors,
|
||||||
|
accountingErrors,
|
||||||
|
contactErrors,
|
||||||
|
addressErrors,
|
||||||
|
ribErrors,
|
||||||
|
mapRowError,
|
||||||
|
} = useClientFormErrors()
|
||||||
|
|
||||||
useHead({ title: t('commercial.clients.form.title') })
|
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 +515,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 +539,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 +625,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 +640,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 +668,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 +676,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 +692,32 @@ 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).
|
||||||
|
if (!mapRowError(error, contactErrors, index)) {
|
||||||
|
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
||||||
|
}
|
||||||
|
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,29 @@ 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 })
|
if (!mapRowError(error, addressErrors, index)) {
|
||||||
|
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
||||||
|
}
|
||||||
|
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 +878,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 +892,54 @@ 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 })
|
if (!mapRowError(error, ribErrors, index)) {
|
||||||
|
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"name": "starseed-frontend",
|
"name": "starseed-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.3",
|
"@malio/layer-ui": "^1.7.4",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -1866,9 +1866,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.7.3",
|
"version": "1.7.4",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.4/layer-ui-1.7.4.tgz",
|
||||||
"integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==",
|
"integrity": "sha512-JNXwBelj5UQ35Qv5VmnassXKt8niX9jDXjM1vUSukJQiyeUXRxAiZr16QumVgBN9P9YGDyjXVKrwCHltTXvPtQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.3",
|
"@malio/layer-ui": "^1.7.4",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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() }))
|
||||||
|
// useI18n stub : renvoie la cle telle quelle (pour asserter dessus).
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
// Titre via i18n (cle renvoyee telle quelle par le stub).
|
||||||
|
expect(mockToastError.mock.calls[0][0]).toMatchObject({ title: 'errors.title', 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -44,7 +44,7 @@ export function useApi(): ApiClient {
|
|||||||
const data = responseData ?? (error as FetchError)?.data
|
const data = responseData ?? (error as FetchError)?.data
|
||||||
const msg = extractApiErrorMessage(data)
|
const msg = extractApiErrorMessage(data)
|
||||||
if (msg) return msg
|
if (msg) return msg
|
||||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
return (error as FetchError)?.message ?? t('errors.unknown')
|
||||||
}
|
}
|
||||||
|
|
||||||
const methodErrorKeys: Record<string, string> = {
|
const methodErrorKeys: Record<string, string> = {
|
||||||
@@ -76,7 +76,7 @@ export function useApi(): ApiClient {
|
|||||||
|
|
||||||
if (successMessage) {
|
if (successMessage) {
|
||||||
toast.success({
|
toast.success({
|
||||||
title: 'Succes',
|
title: t('success.title'),
|
||||||
message: successMessage
|
message: successMessage
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -98,10 +98,10 @@ export function useApi(): ApiClient {
|
|||||||
apiOptions?.toastErrorMessage ||
|
apiOptions?.toastErrorMessage ||
|
||||||
errorMessage ||
|
errorMessage ||
|
||||||
extractedMessage ||
|
extractedMessage ||
|
||||||
'Une erreur est survenue.'
|
t('errors.generic')
|
||||||
|
|
||||||
toast.error({
|
toast.error({
|
||||||
title: apiOptions?.toastTitle ?? 'Erreur',
|
title: apiOptions?.toastTitle ?? t('errors.title'),
|
||||||
message
|
message
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ export function useApi(): ApiClient {
|
|||||||
'Une erreur est survenue.'
|
'Une erreur est survenue.'
|
||||||
|
|
||||||
toast.error({
|
toast.error({
|
||||||
title: apiOptions?.toastTitle ?? 'Erreur',
|
title: apiOptions?.toastTitle ?? t('errors.title'),
|
||||||
message
|
message
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|| t('errors.generic')
|
||||||
|
toast.error({ title: t('errors.title'), 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.' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 :
|
||||||
|
|||||||
Reference in New Issue
Block a user