Compare commits

..

2 Commits

Author SHA1 Message Date
malio c1708cd8f5 Merge branch 'develop' into refactor/refonte-contact-suppression-inline-back
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m54s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m12s
2026-06-03 13:47:23 +00:00
Matthieu 74f0f981d8 refactor(commercial) : suppression du contact principal inline du Client (M1)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m57s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m13s
Le contact principal (firstName, lastName, phonePrimary, phoneSecondary,
email) n'est plus porte par l'entite Client : les contacts vivent uniquement
dans ClientContact (onglet Contact). RG-1.01 et RG-1.02 supprimees du Client
(equivalent RG-1.05 / RG-1.14 sur ClientContact).

- Migration (namespace racine DoctrineMigrations, ordre par timestamp) :
  backfill des clients sans contact vers client_contact (position 0) puis
  DROP des 5 colonnes inline. down() best-effort documente.
- Entite Client : retrait des 5 props + getters/setters + groupes.
- ClientProcessor : MAIN_FIELDS / changedBusinessFields / normalize alleges,
  validateMainContact (RG-1.01) supprimee.
- Recherche repertoire : companyName seul (D1).
- Export XLSX : colonnes de contact retirees (D2).
- Fixtures + catalogue de commentaires SQL alignes.
- Tests fonctionnels et unitaires mis a jour.
2026-06-03 15:31:01 +02:00
41 changed files with 715 additions and 2030 deletions
-34
View File
@@ -44,40 +44,6 @@ Tout champ de formulaire / filtre doit utiliser les composants `Malio*` plutot q
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
Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` :
+153 -315
View File
@@ -1,362 +1,209 @@
# Starseed
CRM/ERP en architecture **modular monolith DDD** — Symfony 8 (API Platform 4) + Nuxt 4.
Le backend est la **source de vérité unique** : il décide des modules actifs et de
l'organisation de la sidebar. Le frontend scanne `frontend/modules/*/` comme layers
Nuxt et consomme l'API pour la navigation.
---
## Sommaire
- [Stack](#stack)
- [Prérequis](#prérequis)
- [Démarrage rapide](#démarrage-rapide)
- [Dev local : avec ou sans données de seed](#dev-local--avec-ou-sans-données-de-seed)
- [Comptes (dev)](#comptes-dev)
- [Bases de données : dev et test](#bases-de-données--dev-et-test)
- [Tests](#tests)
- [Déploiement : seed RBAC en recette / prod](#déploiement--seed-rbac-en-recette--prod)
- [Commandes make](#commandes-make)
- [Architecture](#architecture)
- [Structure du dépôt](#structure-du-dépôt)
- [CI/CD](#cicd)
- [Conventions](#conventions)
---
CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
## Stack
- **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16
- **Frontend** : Nuxt 4 (SPA, SSR off), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, @nuxtjs/i18n
- **Auth** : JWT HTTP-only cookie (Lexik), login sur `/login_check`
- **Frontend** : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui
- **Auth** : JWT HTTP-only cookie (Lexik)
- **Infra** : Docker Compose (dev + prod multi-stage)
- **CI/CD** : Gitea Actions (auto-tag + build Docker)
## Quick Start
```bash
make start # Demarrer les containers Docker
make install # Composer, migrations, fixtures, build Nuxt
```
Dev frontend (hot reload) :
```bash
make dev-nuxt # Port 3003
```
## Ports
| Service | Port |
|---------------|------|
|------------|------|
| API (Nginx)| 8083 |
| Frontend dev | 3004 |
| Frontend | 3004 |
| PostgreSQL | 5437 |
---
## Commandes
## Prérequis
- Docker + Docker Compose
- `make`
- `nvm` (la version de Node est fixée par `.nvmrc`, voir `make node-use`)
Toutes les commandes `make` s'exécutent dans le container PHP (`php-starseed-fpm`) ;
rien n'est requis sur l'hôte hormis Docker — **sauf les tests E2E**, qui tournent sur
l'hôte (navigateur réel, voir [Tests](#tests)).
---
## Démarrage rapide
```bash
make start # Démarre les containers Docker
make install # Composer + clés JWT + migrations + permissions + BDD de test
make dev-nuxt # Serveur Nuxt avec hot reload (http://localhost:3004)
```
`make install` prépare une base de dev **vierge** (schéma + RBAC structurel, sans
données de démo) et la base de **test**. Pour obtenir des comptes et des données de
démo prêtes à l'emploi, lis la section suivante.
> Override local possible : `make` lit `infra/dev/.env.docker`, surchargé par
> `infra/dev/.env.docker.local` s'il existe (créé automatiquement par `make env-init`).
---
## Dev local : avec ou sans données de seed
Le projet distingue deux états de base de données de dev. Les **fixtures Doctrine sont
en `require-dev`** : elles n'existent qu'en dev, jamais dans le build de prod.
### Sans données de seed (base vierge)
C'est ce que produit `make install`. La base contient :
- le **schéma** complet (toutes les migrations jouées) ;
- les **rôles système** `admin` / `user` (seedés en SQL par la migration RBAC) ;
- le **catalogue de permissions** synchronisé (`app:sync-permissions`).
Mais **aucun compte utilisateur ni donnée métier**. Pour pouvoir te connecter,
crée toi-même un compte :
```bash
make shell
php bin/console app:create-user admin monMotDePasse --admin # compte ROLE_ADMIN
```
Optionnel — provisionner les **rôles métier** (bureau / compta / commerciale / usine
+ matrice RBAC § 2.7) sans comptes de démo :
```bash
php bin/console app:seed-rbac
```
Cet état est utile pour repartir d'une base propre, reproduire un bug sur données
minimales, ou tester un parcours d'onboarding réel.
### Avec données de seed (base de démo)
`make db-reset` (ou `make fixtures` après un `make install`) recharge la base de dev
avec un jeu complet de données de démonstration, **idempotent** :
```bash
make db-reset # ATTENTION : drop + recrée la base de dev, puis charge tout le seed
```
Ce que les fixtures posent :
- **3 utilisateurs système** : `admin` (ROLE_ADMIN), `alice`, `bob` (ROLE_USER),
rattachés à des sites distincts ;
- **3 sites** : Chatellerault, Saint-Jean, Pommevic ;
- **les comptes de démo RBAC métier** (`bureau`, `compta`, `commerciale`, `usine`,
mot de passe `demo`) avec la matrice § 2.7 attachée ;
- les **référentiels et données métier** des modules (catégories, clients de démo,
référentiels comptables…).
Toutes les fixtures sont rejouables sans effet de bord (lookup par clé naturelle,
aucun doublon).
> Différence avec `make install` : `install` ne charge **pas** les fixtures sur la base
> de dev (il alimente uniquement la base de test). Utilise `make db-reset` ou
> `make fixtures` quand tu veux des données de démo en dev.
---
## Comptes (dev)
Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec seed ») :
| Username | Password | Rôle | RBAC métier |
|---------------|----------|------------|---------------------------------------------------------------|
| `admin` | `admin` | ROLE_ADMIN | bypass complet (`is_admin`) |
| `alice` | `alice` | ROLE_USER | — |
| `bob` | `bob` | ROLE_USER | — |
| `bureau` | `demo` | ROLE_USER | clients : view + manage |
| `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage |
| `commerciale` | `demo` | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| `usine` | `demo` | ROLE_USER | aucun accès clients |
---
## Bases de données : dev et test
Deux bases distinctes vivent dans le **même container PostgreSQL** (port 5437) :
| Base | Environnement | Construite par | Usage |
|------------|---------------|--------------------------------------|--------------------------------|
| `<db>` | `dev` | `make install` / `make db-reset` | développement manuel, dev-nuxt |
| `<db>_test` | `test` | `make test-db-setup` | PHPUnit (jamais touchée à la main) |
Le suffixe `_test` est appliqué **automatiquement** par Doctrine quand `APP_ENV=test`
(config `when@test` dans `config/packages/doctrine.yaml`). La base de test est donc
totalement **isolée** de la base de dev : lancer `make test` ne touche jamais tes
données de dev.
`make test-db-setup` fait davantage que jouer les migrations, car certaines structures
ne sont pas portées par des migrations « métier » :
1. `doctrine:migrations:migrate` — schéma métier réel ;
2. `doctrine:schema:update --force` — crée les tables mappées en `when@test`
uniquement (entités de test) ;
3. `app:apply-column-comments` — réapplique les `COMMENT ON COLUMN` que
`schema:update` efface sur les tables managées par l'ORM (garde-fou
`ColumnsHaveSqlCommentTest`) ;
4. `fixtures:load``sync-permissions``seed-rbac` — dans cet ordre précis
(le purger des fixtures vide la table `permission`, donc la sync passe après) ;
5. recréation des **index partiels uniques** (`LOWER(...) WHERE ...`) non exprimables
en attributs ORM, indispensables aux tests d'unicité (RG-1.07, RG-1.16, RG-1.03/1.29).
`make install` et `make db-reset` appellent déjà `test-db-setup` : tu n'as à le
relancer à la main que si la base de test diverge (nouvelle migration, nouvelle
permission) sans vouloir reseed la base de dev.
---
| Commande | Description |
|----------|-------------|
| `make start` | Demarrer les containers |
| `make stop` | Arreter les containers |
| `make restart` | Redemarrer les containers |
| `make install` | Install complet |
| `make reset` | Tout supprimer et reinstaller |
| `make dev-nuxt` | Serveur dev Nuxt (hot reload) |
| `make shell` | Shell dans le container PHP |
| `make cache-clear` | Vider le cache Symfony |
| `make migration-migrate` | Lancer les migrations |
| `make fixtures` | Charger les fixtures |
| `make db-reset` | Reset BDD + migrations + fixtures |
| `make test` | PHPUnit (tests back) |
| `make nuxt-test` | Vitest (tests unitaires front) |
| `make test-e2e` | Playwright (tests E2E front) |
| `make test-e2e-ui` | Playwright UI interactive (debug) |
| `make seed-e2e` | Seed les 6 personas E2E |
| `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) |
| `make php-cs-fixer-allow-risky` | Fix code style PHP |
| `make logs-dev` | Tail logs Symfony |
## Tests
| Suite | Commande | Outil | Où |
|-------------------|------------------|----------------------|-----------------------------------|
| Back | `make test` | PHPUnit | container PHP, base `<db>_test` |
| Front unitaire | `make nuxt-test` | Vitest (happy-dom) | container Node, < 30 s |
| Front E2E | `make test-e2e` | Playwright | **hôte** (navigateur réel requis) |
| Tout (back+front) | `make test-all` | PHPUnit + Vitest | — |
### Tests back (PHPUnit)
- **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`.
- **Front unitaire** : `make nuxt-test` (Vitest, happy-dom). Composables, utils, stores — rapide, <30s.
- **Front E2E** : `make test-e2e` (Playwright). Couvre login + matrice RBAC sidebar. Suite volontairement minimaliste (11 tests) — voir la regle d'or dans `CLAUDE.md`.
**Bootstrap E2E (une fois par poste)** :
```bash
make test # toute la suite
make test FILES=tests/Module/Commercial # un dossier / fichier ciblé
make install-e2e-deps # Telecharge Chromium + libs systeme via apt (sudo)
```
PHPUnit force `APP_ENV=test` (`phpunit.dist.xml`) : les tests tournent **toujours**
sur la base `<db>_test`, jamais sur la base de dev. Prérequis : que la base de test
existe — c'est le cas après `make install`. Si elle a divergé, rejoue
`make test-db-setup` (cf. [Bases de données](#bases-de-données--dev-et-test)).
### Tests front unitaires (Vitest)
**Workflow E2E** :
```bash
make nuxt-test # composables, utils, stores — rapide et stable
```
C'est la **place par défaut** pour étendre la couverture (cf. règle d'or ci-dessous).
### Tests E2E (Playwright)
Suite volontairement minimaliste (login + matrice RBAC sidebar). **Règle d'or : un
nouveau test E2E ne s'ajoute que si un bug critique est passé en prod** — sinon,
préférer un test Vitest ou étendre un persona existant.
Bootstrap (une fois par poste) :
```bash
make install-e2e-deps # télécharge Chromium + libs système (apt/dnf, sudo)
```
Workflow :
```bash
# Terminal 1 — containers, seed des personas, serveur dev
# Terminal 1 : containers + dev server
make start && make seed-e2e && make dev-nuxt
# Terminal 2 tests
make test-e2e # headless
make test-e2e-ui # UI interactive (debug)
# Terminal 2 : tests
make test-e2e
```
> Toute permission testable touche **3 miroirs** à garder alignés : `config/sidebar.php`,
> `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
---
## Déploiement : seed RBAC en recette / prod
Les fixtures Doctrine étant en `require-dev`, elles sont **absentes du build de prod**.
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
est seedé par une **commande applicative idempotente**, jouée dans l'étape de release,
**après** les migrations et la synchronisation des permissions :
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:sync-permissions # pose les permissions (commercial.clients.*, …)
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
```
En **recette / staging**, ajouter le flag pour disposer de logins de test. Le mot de
passe est fourni **explicitement** (jamais en dur, jamais committé) :
```bash
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
# ou via la variable d'environnement RBAC_DEMO_PASSWORD
```
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de
compte). Pour créer un premier administrateur en prod :
```bash
php bin/console app:create-user <username> <password> --admin
```
---
## Commandes make
`make` (sans argument) ou `make help` affiche l'aide colorée. Les principales :
| Commande | Description |
|--------------------------------|----------------------------------------------------------|
| `make start` / `stop` / `restart` | Cycle de vie des containers |
| `make install` | Install complet (base dev vierge + base de test) |
| `make reset` | Tout supprimer et réinstaller (**drop la BDD**) |
| `make dev-nuxt` | Serveur Nuxt hot reload (port 3004) |
| `make shell` / `shell-root` | Shell bash dans le container PHP |
| `make migration-migrate` | Jouer les migrations Doctrine |
| `make fixtures` | Charger les fixtures (données de démo dev) |
| `make sync-permissions` | Synchroniser le catalogue RBAC |
| `make seed-rbac` | Seed RBAC métier (rôles + matrice § 2.7) |
| `make db-reset` | Reset base dev : drop + migrate + fixtures + RBAC |
| `make test-db-setup` | (Re)construire la base de test |
| `make test` | PHPUnit (back) |
| `make nuxt-test` | Vitest (front unitaire) |
| `make test-all` | PHPUnit + Vitest |
| `make test-e2e` / `test-e2e-ui`| Playwright (E2E, sur l'hôte) |
| `make seed-e2e` | Seed des 6 personas E2E |
| `make php-cs-fixer-allow-risky`| Fix du code style PHP |
| `make php-cs-fixer-check` | Dry-run du fixer (CI / avant push) |
| `make logs-dev` | Tail des logs Symfony |
---
## Architecture
**Modular Monolith DDD** : chaque module est un bounded context autonome,
activable / désactivable par tenant. Le backend est la seule source de vérité pour
l'activation des modules et l'organisation de la sidebar.
**Modular Monolith DDD** : chaque module est un bounded context autonome, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar.
- `config/modules.php` — liste des modules actifs
- `config/sidebar.php` — structure de la sidebar (sections + items avec module owner)
- `GET /api/modules` — IDs des modules actifs (public)
- `GET /api/sidebar` — sections filtrées par modules actifs + routes désactivées (public)
- `GET /api/sidebar` — retourne les sections filtrees par les modules actifs + les routes desactivees
- Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API
**Désactiver un module** : commenter sa ligne dans `config/modules.php`, vider le cache.
Ses items de sidebar disparaissent et ses routes sont bloquées par le middleware front.
Le code reste dans le bundle (layer auto-détecté) → réactivation instantanée.
Pour desactiver un module : commenter sa ligne dans `config/modules.php`, clear cache. Ses items de sidebar disparaissent et ses routes sont bloquees par le middleware front.
**Réorganiser la sidebar** : éditer `config/sidebar.php` uniquement le code des
modules n'est pas touché.
Pour reorganiser la sidebar (ex: deplacer un item d'une section a l'autre) : editer `config/sidebar.php` uniquement, le code des modules n'est pas touche.
**Communication inter-modules** : jamais d'import direct d'un module à l'autre. Passer
par `Shared/Domain/Contract/` (interfaces) ou des domain events.
---
## Structure du dépôt
## Structure
```
src/ # Backend Symfony
Shared/ # Noyau technique partagé (Domain/, Application/Bus/, Infrastructure/ApiPlatform/)
Kernel.php
Shared/ # Noyau technique partage
Domain/
ValueObject/ # Email, ...
Event/ # DomainEventInterface
Contract/ # Interfaces inter-modules
Application/
Bus/ # CommandBusInterface, QueryBusInterface
Infrastructure/
ApiPlatform/
Resource/ # AppVersion, ModulesResource, SidebarResource
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
Module/
Core/ # Module obligatoire (auth, users, RBAC)
CoreModule.php # Déclaration (ID, LABEL, REQUIRED, permissions())
Domain/ Application/ Infrastructure/
Commercial/ Catalog/ Sites/ # Modules métier
Core/ # Module obligatoire (auth, users)
CoreModule.php # Declaration (ID, LABEL, REQUIRED)
Domain/
Entity/ # User
Repository/ # UserRepositoryInterface
Event/ # UserCreated
Application/
DTO/ # UserOutput
Infrastructure/
Doctrine/ # DoctrineUserRepository, Migrations/
ApiPlatform/State/
Provider/ # MeProvider
Processor/ # UserPasswordHasherProcessor
Console/ # CreateUserCommand
DataFixtures/ # AppFixtures
Commercial/ # Autre module (exemple)
CommercialModule.php
config/
modules.php # Source de vérité : activation
sidebar.php # Source de vérité : navigation
packages/ # Config Symfony (doctrine, api_platform, security…)
migrations/ # Migrations d'initialisation (namespace racine : setup, RBAC, seed de base)
modules.php # Source de verite activation
sidebar.php # Source de verite navigation
version.yaml
packages/ # Config Symfony
jwt/ # Cles JWT
migrations/ # Anciennes migrations
frontend/ # App Nuxt 4 (SPA)
app/ # Shell : layouts, middlewares (auth.global, modules.global)
shared/ # Code inter-modules (composables, stores, utils, types)
modules/ # Layers Nuxt auto-détectés (core/, commercial/…)
i18n/locales/ # Traductions (sidebar.*, audit.entity.*, …)
app/
layouts/ # default.vue, auth.vue
middleware/ # auth.global.ts, modules.global.ts
shared/ # Code partage (hors modules)
composables/ # useApi, useAppVersion, useSidebar
components/ui/ # AppTopNav, ...
stores/ # auth, ui
services/ # auth
types/ # SidebarSection, UserData
utils/ # api (Hydra)
modules/ # Modules auto-detectes comme layers Nuxt
core/
nuxt.config.ts # Marqueur layer
pages/ # index, login, logout
commercial/
nuxt.config.ts
pages/ # commercial.vue
app.vue
nuxt.config.ts # Scanne modules/*/ automatiquement
i18n/locales/ # Traductions (sidebar.*, etc.)
assets/ # CSS, images
public/ # Fichiers statiques
infra/
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug, .env.docker)
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
.gitea/workflows/ # CI Gitea (auto-tag, build Docker)
.claude/
skills/create-module/ # Skill Claude Code pour scaffolder un module
```
---
## CI/CD
- **Auto Tag** : push sur `develop` → bump `config/version.yaml` → tag `vX.Y.Z`
- **Build Docker** : push tag `v*` → build image multi-stage → push Gitea Registry
Secrets requis dans Gitea :
- `RELEASE_TOKEN` — PAT avec droits `write:repository`
- `REGISTRY_TOKEN` — token pour le registry Docker
---
## Déploiement — seed RBAC (recette / prod)
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
est seedé par une **commande applicative idempotente** (présente dans le build prod,
contrairement aux fixtures Doctrine en `require-dev`). À jouer dans l'étape de release,
**après** les migrations et la synchronisation des permissions :
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:sync-permissions # pose les permissions commercial.clients.*
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
```
En **recette / staging**, ajouter le flag pour disposer de logins de test (mot de passe
fourni explicitement, jamais en dur) :
```bash
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
# ou via la variable d'env RBAC_DEMO_PASSWORD
```
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de compte).
En dev, `make db-reset` produit le même résultat (rôles + matrice + comptes démo).
## Credentials (dev)
| Username | Password | Role | RBAC métier |
|----------|----------|------|-------------|
| admin | admin | ROLE_ADMIN | bypass (is_admin) |
| alice | alice | ROLE_USER | — |
| bob | bob | ROLE_USER | — |
| bureau | demo | ROLE_USER | clients : view + manage |
| compta | demo | ROLE_USER | clients : view + accounting.view/manage |
| commerciale | demo | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| usine | demo | ROLE_USER | aucun accès clients |
## Conventions
@@ -366,13 +213,4 @@ Secrets requis dans Gitea :
<type>(<scope optionnel>) : <message>
```
Espaces obligatoires autour du `:`. Types : `build`, `chore`, `ci`, `docs`, `feat`,
`fix`, `perf`, `refactor`, `revert`, `style`, `test`.
### Langue
- UI et communication : **français**
- Code (classes, méthodes, variables) : **anglais**
- Commentaires (PHP, TS, Vue) : **français**
> Règles détaillées : `CLAUDE.md` et `.claude/rules/`.
Types : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.79'
app.version: '0.1.74'
+1 -2
View File
@@ -258,8 +258,7 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.
- Composable dédié `useAddressAutocomplete()` (à créer en M1).
- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back.
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}` (sans filtre `type`) → suggestions adresse.
-**Ne pas forcer `type=housenumber`** sur la recherche d'adresse (corrigé en ERP-66) : la BAN ne renvoie un résultat de ce type qu'une fois un numéro saisi, donc une recherche par nom de rue (« boulevard du port ») renverrait **0 résultat** pendant toute la frappe. Sans filtre `type`, la BAN classe rues + numéros par pertinence — comportement d'autocomplétion attendu.
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse.
- Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre.
## Points laissés ouverts par la V0 (résolus côté back)
@@ -1,119 +0,0 @@
# 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**.
+13 -12
View File
@@ -10,11 +10,7 @@
"confirm": "Confirmer",
"yes": "Oui",
"no": "Non",
"actions": "Actions",
"comingSoon": {
"title": "En cours de dev",
"subtitle": "Cette fonctionnalité arrive bientôt."
}
"actions": "Actions"
},
"sidebar": {
"administration": {
@@ -99,6 +95,8 @@
"back": "Retour au répertoire",
"loading": "Chargement du client…",
"notFound": "Client introuvable.",
"emptyContacts": "Aucun contact enregistré.",
"emptyAddresses": "Aucune adresse enregistrée.",
"confirmArchive": {
"title": "Archiver le client",
"message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
@@ -113,6 +111,8 @@
"back": "Retour au répertoire",
"loading": "Chargement du client…",
"notFound": "Client introuvable.",
"emptyContacts": "Aucun contact enregistré.",
"emptyAddresses": "Aucune adresse enregistrée.",
"save": "Valider"
},
"validation": {
@@ -133,9 +133,14 @@
"duplicateCompany": "Un client portant ce nom de société existe déjà.",
"main": {
"companyName": "Nom du client (Entreprise)",
"firstName": "Prénom du contact principal",
"lastName": "Nom du contact principal",
"email": "Email",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro",
"categories": "Catégorie",
"relation": "Distributeur / Courtier",
"relationNone": "Aucun",
"relationDistributor": "Dépend du distributeur",
"relationBroker": "Dépend du courtier",
"distributorName": "Nom du distributeur",
@@ -228,10 +233,7 @@
},
"sites": {
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
},
"title": "Erreur",
"generic": "Une erreur est survenue.",
"unknown": "Erreur inconnue."
}
},
"sites": {
"selector": {
@@ -288,8 +290,7 @@
"success": {
"auth": {
"logout": "Deconnexion reussie"
},
"title": "Succès"
}
},
"admin": {
"roles": {
@@ -20,7 +20,7 @@
:label="t('admin.categories.form.name')"
input-class="w-full"
:max-length="120"
:error="form.errors.name"
:error="form.errors.value.name"
required
/>
@@ -32,9 +32,15 @@
:options="typeOptions"
:label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="form.errors.categoryType"
:error="form.errors.value.categoryType"
: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>
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useCategoryForm } from '../useCategoryForm'
// Stubs des auto-imports Nuxt consommes par le composable.
@@ -22,9 +21,6 @@ vi.stubGlobal('useToast', () => ({
success: mockToastSuccess,
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).
// Quand le composable passe des params (ex: doublon), on les serialise pour
// pouvoir verifier que l'interpolation a bien recu le bon nom.
@@ -65,7 +61,7 @@ describe('useCategoryForm', () => {
expect(form.name.value).toBe('Vis')
expect(form.categoryTypeId.value).toBe(1)
expect(form.errors).toEqual({})
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
})
it('vide le formulaire en mode creation (null)', () => {
@@ -109,7 +105,7 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameRequired')
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
})
it('signale erreur si name est whitespace-only (trim → vide)', () => {
@@ -120,7 +116,7 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameRequired')
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
})
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
@@ -131,7 +127,7 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
@@ -142,7 +138,7 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
@@ -153,7 +149,7 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
})
it('passe quand name et categoryType sont valides', () => {
@@ -164,22 +160,19 @@ describe('useCategoryForm', () => {
const ok = form.validate()
expect(ok).toBe(true)
expect(form.errors).toEqual({})
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
})
it('reinitialise les erreurs avant chaque validation', () => {
const form = useCategoryForm()
// Erreur prealable : une validation en echec peuple errors.name.
form.name.value = ''
form.categoryTypeId.value = 1
form.validate()
expect(form.errors.name).toBeTruthy()
// Seconde validation avec des valeurs valides : errors repart vide.
// Erreur prealable.
form.errors.value._global = 'erreur ancienne'
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.validate()
expect(form.errors).toEqual({})
expect(form.errors.value._global).toBe('')
})
})
@@ -220,7 +213,7 @@ describe('useCategoryForm', () => {
await form.submitCreate()
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'success.title',
title: 'Succès',
message: 'admin.categories.toast.created',
})
})
@@ -238,8 +231,8 @@ describe('useCategoryForm', () => {
expect(result).toBeNull()
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
// les params i18n (stub serialise les params).
expect(form.errors.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.name).toContain('"name":"Vis"')
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Vis"')
expect(mockToastError).toHaveBeenCalledTimes(1)
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
expect(toastArg.message).toContain('Vis')
@@ -263,7 +256,7 @@ describe('useCategoryForm', () => {
const result = await form.submitCreate()
expect(result).toBeNull()
expect(form.errors.name).toBe('name should not be blank.')
expect(form.errors.value.name).toBe('name should not be blank.')
// Pas de toast quand on a mappe les violations : l erreur est
// affichee inline sous le champ concerne.
expect(mockToastError).not.toHaveBeenCalled()
@@ -286,10 +279,10 @@ describe('useCategoryForm', () => {
await form.submitCreate()
expect(form.errors.categoryType).toBe('Type invalide.')
expect(form.errors.value.categoryType).toBe('Type invalide.')
})
it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
mockPost.mockRejectedValueOnce({
response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
})
@@ -299,10 +292,9 @@ describe('useCategoryForm', () => {
await form.submitCreate()
// Pas d'erreur inline par champ : l'erreur transverse part en toast.
expect(form.errors).toEqual({})
expect(form.errors.value._global).toBe('Boom server')
expect(mockToastError).toHaveBeenCalledWith({
title: 'errors.title',
title: 'Erreur',
message: 'Boom server',
})
})
@@ -378,7 +370,7 @@ describe('useCategoryForm', () => {
await form.submitUpdate(42)
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'success.title',
title: 'Succès',
message: 'admin.categories.toast.updated',
})
})
@@ -394,8 +386,8 @@ describe('useCategoryForm', () => {
const result = await form.submitUpdate(42)
expect(result).toBeNull()
expect(form.errors.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.name).toContain('"name":"Doublon"')
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Doublon"')
})
})
@@ -409,7 +401,7 @@ describe('useCategoryForm', () => {
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
expect(ok).toBe(true)
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'success.title',
title: 'Succès',
message: 'admin.categories.toast.deleted',
})
})
@@ -423,6 +415,7 @@ describe('useCategoryForm', () => {
const ok = await form.submitDelete(42)
expect(ok).toBe(false)
expect(form.errors.value._global).toBe('down')
expect(mockToastError).toHaveBeenCalled()
})
})
@@ -431,15 +424,15 @@ describe('useCategoryForm', () => {
it('vide le formulaire et les erreurs', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = ''
form.validate() // peuple errors.name
form.name.value = 'edit'
form.errors.value._global = 'erreur'
form.submitting.value = true
form.reset()
expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull()
expect(form.errors).toEqual({})
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
expect(form.submitting.value).toBe(false)
})
})
@@ -12,13 +12,14 @@
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
* revalide toujours (defense en profondeur).
*
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
* violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ;
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
* RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast.
* Mapping erreurs API :
* - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name`
* - 422 (violations API Platform) → mapping sur les champs concernes
* - autre erreur globale `_global` + toast generique
*/
import { computed, ref } from 'vue'
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
@@ -36,9 +37,6 @@ export function useCategoryForm() {
const { t } = useI18n()
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
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('')
@@ -50,6 +48,16 @@ export function useCategoryForm() {
const initialName = ref('')
const initialCategoryTypeId = ref<number | null>(null)
const errors = ref<{
name: string
categoryType: string
_global: string
}>({
name: '',
categoryType: '',
_global: '',
})
const submitting = ref(false)
const isDirty = computed(
@@ -64,7 +72,7 @@ export function useCategoryForm() {
* erreurs et le snapshot initial pour repartir d'un etat propre.
*/
function loadFrom(category: Category | null): void {
formErrors.clearErrors()
errors.value = { name: '', categoryType: '', _global: '' }
if (category) {
name.value = category.name
categoryTypeId.value = category.categoryType.id
@@ -84,29 +92,32 @@ export function useCategoryForm() {
* mais le serveur retrim de toute facon — pas de risque de divergence.
*/
function validate(): boolean {
formErrors.clearErrors()
errors.value = { name: '', categoryType: '', _global: '' }
const trimmedName = name.value.trim()
// RG-1.02 — name obligatoire (vide / whitespace-only).
if (trimmedName === '') {
formErrors.setError('name', t('admin.categories.validation.nameRequired'))
errors.value.name = t('admin.categories.validation.nameRequired')
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
// RG-1.04 — longueur 2-120 apres trim.
formErrors.setError('name', t('admin.categories.validation.nameLength'))
errors.value.name = t('admin.categories.validation.nameLength')
}
// RG-1.05 — categoryType obligatoire.
if (categoryTypeId.value === null) {
formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
errors.value.categoryType = t('admin.categories.validation.typeRequired')
}
return !formErrors.errors.name && !formErrors.errors.categoryType
return errors.value.name === '' && errors.value.categoryType === ''
}
/**
* Construit le payload POST a partir du state. Le `categoryType` est
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
* Platform pour referencer une ressource liee.
* Platform pour referencer une ressource liee. Retourne un object literal
* 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> {
return {
@@ -116,24 +127,72 @@ export function useCategoryForm() {
}
/**
* Traite une erreur API : 409 (doublon RG-1.07) → erreur inline sur `name`
* + toast ; sinon delegue a `useFormErrors.handleApiError` (422 mappe inline
* par champ sans toast, autre → toast de fallback). Retourne true si traitee
* inline (409/422 mappe), false si fallback toast.
* Mappe les violations 422 d'API Platform sur les champs du formulaire.
* Renvoie true des qu'au moins une violation a ete posee — false sinon
* (payload sans violations exploitables, ou tous les `propertyPath` hors
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
* 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 {
const status = (e as ApiFetchError)?.response?.status
const data = (e as ApiFetchError)?.response?._data
if (status === 409) {
const duplicateMessage = t('admin.categories.toast.duplicate', {
name: attemptedName,
})
formErrors.setError('name', duplicateMessage)
toast.error({ title: t('errors.title'), message: duplicateMessage })
errors.value.name = duplicateMessage
toast.error({
title: 'Erreur',
message: duplicateMessage,
})
return true
}
return formErrors.handleApiError(e, { fallbackMessage: t('errors.generic') })
if (status === 422 && mapServerViolations(data)) {
return true
}
const extracted = extractApiErrorMessage(data)
errors.value._global = extracted || 'Une erreur est survenue.'
toast.error({
title: 'Erreur',
message: errors.value._global,
})
return false
}
/**
@@ -144,13 +203,14 @@ export function useCategoryForm() {
async function submitCreate(): Promise<Category | null> {
if (!validate()) return null
submitting.value = true
errors.value._global = ''
const payload = buildCreatePayload()
try {
const created = await api.post<Category>('/categories', payload, {
toast: false,
})
toast.success({
title: t('success.title'),
title: 'Succès',
message: t('admin.categories.toast.created'),
})
return created
@@ -170,6 +230,7 @@ export function useCategoryForm() {
async function submitUpdate(id: number): Promise<Category | null> {
if (!validate()) return null
submitting.value = true
errors.value._global = ''
const payload: Record<string, unknown> = {}
if (name.value !== initialName.value) {
payload.name = name.value.trim()
@@ -189,7 +250,7 @@ export function useCategoryForm() {
toast: false,
})
toast.success({
title: t('success.title'),
title: 'Succès',
message: t('admin.categories.toast.updated'),
})
return updated
@@ -211,11 +272,11 @@ export function useCategoryForm() {
*/
async function submitDelete(id: number): Promise<boolean> {
submitting.value = true
formErrors.clearErrors()
errors.value._global = ''
try {
await api.delete(`/categories/${id}`, {}, { toast: false })
toast.success({
title: t('success.title'),
title: 'Succès',
message: t('admin.categories.toast.deleted'),
})
return true
@@ -236,7 +297,7 @@ export function useCategoryForm() {
categoryTypeId.value = null
initialName.value = ''
initialCategoryTypeId.value = null
formErrors.clearErrors()
errors.value = { name: '', categoryType: '', _global: '' }
submitting.value = false
}
@@ -244,7 +305,7 @@ export function useCategoryForm() {
// State
name,
categoryTypeId,
errors: formErrors.errors,
errors,
submitting,
isDirty,
// Methods
@@ -44,8 +44,7 @@
:options="categoryOptions"
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:required="true"
:disabled="readonly"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
@@ -53,8 +52,7 @@
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.clients.form.address.country')"
:readonly="readonly"
:required="true"
:disabled="readonly"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
@@ -63,8 +61,6 @@
:label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
@@ -75,10 +71,8 @@
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:disabled="readonly"
empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
@@ -86,8 +80,6 @@
:model-value="model.city"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
@@ -107,8 +99,6 @@
:min-search-length="3"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
@@ -118,8 +108,6 @@
:model-value="model.street"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
@@ -129,7 +117,6 @@
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
:readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
@@ -152,7 +139,7 @@
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
@@ -164,7 +151,6 @@
:label="t('commercial.clients.form.address.billingEmail')"
:required="true"
:readonly="readonly"
:error="errors?.billingEmail"
@update:model-value="(v: string) => update('billingEmail', v)"
/>
</div>
@@ -197,8 +183,6 @@ const props = defineProps<{
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
@@ -217,8 +201,7 @@ const model = computed(() => props.modelValue)
const degraded = ref(false)
// Villes proposees par la BAN (alimentees a la saisie du code postal).
const banCityOptions = ref<RefOption[]>([])
// Adresses proposees par la BAN (alimentees a la saisie d'adresse).
const banAddressOptions = ref<RefOption[]>([])
const addressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options)
@@ -231,20 +214,6 @@ const cityOptions = computed<RefOption[]>(() => {
}
return banCityOptions.value
})
// Meme garantie que cityOptions pour le champ Adresse : la rue courante doit
// toujours figurer dans les options, sinon MalioInputAutocomplete (qui resout
// l'affichage depuis ses options) laisse le champ VIDE des que la liste de
// suggestions BAN est vide — typiquement juste apres validation (remontage) ou
// a l'edition d'une adresse existante (1.12), alors que la valeur est bien
// persistee. On reinjecte donc la rue liee si la BAN ne l'a pas (re)proposee.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
@@ -311,7 +280,7 @@ async function onAddressSearch(query: string): Promise<void> {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
enterDegraded()
@@ -16,28 +16,24 @@
:model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')"
:readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
:model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')"
:readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
<MalioInputEmail
:model-value="model.email"
:label="t('commercial.clients.form.contact.email')"
:readonly="readonly"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
@@ -45,7 +41,6 @@
:label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@@ -57,7 +52,6 @@
:label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
@@ -79,8 +73,6 @@ const props = defineProps<{
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
@@ -0,0 +1,14 @@
<template>
<!--
Placeholder des onglets non encore implementes (Transport, Statistiques,
Rapports, Echanges). Frame vide blanche : aucun champ, aucun bouton,
aucun message « En cours » (decision Tristan 28/05). L'orchestrateur passe
automatiquement a l'onglet suivant ce composant n'est qu'une coquille
visuelle reutilisee par 1.11/1.12.
-->
<div class="min-h-[240px] rounded-md bg-white" />
</template>
<script setup lang="ts">
// Composant purement presentationnel : aucune prop, aucun event.
</script>
@@ -1,132 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyAddress } from '~/modules/commercial/types/clientForm'
import ClientAddressBlock from '../ClientAddressBlock.vue'
// Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee.
// On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions
// vide » (remontage apres validation / edition d'une adresse existante).
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: vi.fn(),
searchAddress: vi.fn(),
}),
}))
// 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 de MalioInputAutocomplete : expose les `value` des options recues, pour
// verifier que la rue courante figure bien dans la liste (sinon le composant
// Malio ne peut pas resoudre/afficher la valeur liee -> champ vide).
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: {
modelValue: { type: [String, Number, null], default: undefined },
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
loading: { type: Boolean, default: false },
minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', {
'data-testid': 'addr-autocomplete',
'data-options': JSON.stringify(props.options.map(o => o.value)),
})
},
})
function mountBlock(street: string | null) {
return mount(ClientAddressBlock, {
props: {
modelValue: { ...emptyAddress(), street },
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
it('inclut la rue courante dans les options de l\'autocomplete meme sans recherche BAN', () => {
const wrapper = mountBlock('8 Boulevard du Port')
const el = wrapper.find('[data-testid="addr-autocomplete"]')
const values = JSON.parse(el.attributes('data-options') ?? '[]')
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.')
})
})
@@ -1,64 +0,0 @@
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('')
})
})
@@ -1,54 +0,0 @@
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()
})
})
@@ -28,7 +28,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
return Promise.reject(new Error('403 Forbidden'))
}
if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
}
return Promise.resolve({
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
@@ -40,8 +40,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
await refs.loadCommon()
// Resilience : les referentiels OK sont peuples malgre l'echec de /categories.
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
@@ -57,7 +56,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
})
}
if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
}
return Promise.resolve({ member: [] })
})
@@ -68,7 +67,6 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.categories.value).toEqual([
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
])
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
})
})
@@ -1,55 +0,0 @@
/**
* 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,
}
}
@@ -45,7 +45,6 @@ interface CategoryMember extends HydraMember {
interface SiteMember extends HydraMember {
name: string
postalCode: string
}
interface ReferentialMember extends HydraMember {
@@ -102,10 +101,7 @@ export function useClientReferentials() {
fetchAll<CategoryMember>('/categories')
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja
// expose par /sites (groupe site:read) — aucune colonne a ajouter.
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }),
fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays')
@@ -28,24 +28,54 @@
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioInputText
v-model="main.lastName"
:label="t('commercial.clients.form.main.lastName')"
:readonly="businessReadonly"
/>
<MalioInputText
v-model="main.firstName"
:label="t('commercial.clients.form.main.firstName')"
:readonly="businessReadonly"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
:disabled="businessReadonly"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioInputPhone
v-model="main.phonePrimary"
:label="t('commercial.clients.form.main.phonePrimary')"
:mask="PHONE_MASK"
:required="true"
:readonly="businessReadonly"
add-icon-name="mdi:plus"
:addable="!main.hasSecondaryPhone && !businessReadonly"
:add-button-label="t('commercial.clients.form.main.addPhone')"
@add="main.hasSecondaryPhone = true"
/>
<MalioInputPhone
v-if="main.hasSecondaryPhone"
v-model="main.phoneSecondary"
:label="t('commercial.clients.form.main.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="businessReadonly"
/>
<MalioInputEmail
v-model="main.email"
:label="t('commercial.clients.form.main.email')"
:required="true"
:readonly="businessReadonly"
/>
<MalioSelect
:model-value="main.relationType"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="businessReadonly"
:disabled="businessReadonly"
@update:model-value="onRelationChange"
/>
<MalioSelect
@@ -53,9 +83,7 @@
:model-value="main.brokerIri"
:options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.broker"
:disabled="businessReadonly"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/>
<MalioSelect
@@ -63,9 +91,7 @@
:model-value="main.distributorIri"
:options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.distributor"
:disabled="businessReadonly"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/>
<MalioCheckbox
@@ -86,7 +112,7 @@
</div>
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet 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)]">
@@ -96,45 +122,38 @@
resize="none"
group-class="row-span-2 pt-1"
text-input="h-full text-lg"
:readonly="businessReadonly"
:error="informationErrors.errors.description"
:disabled="businessReadonly"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')"
:readonly="businessReadonly"
:error="informationErrors.errors.competitors"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="businessReadonly"
:error="informationErrors.errors.employeesCount"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
:readonly="businessReadonly"
:error="informationErrors.errors.revenueAmount"
:disabled="businessReadonly"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')"
:readonly="businessReadonly"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
:readonly="businessReadonly"
:error="informationErrors.errors.profitAmount"
:disabled="businessReadonly"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
@@ -157,10 +176,12 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyContacts') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
@@ -194,11 +215,13 @@
:country-options="countryOptions"
:removable="addresses.length > 1"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<p v-if="addresses.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyAddresses') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
@@ -222,57 +245,45 @@
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
@@ -280,10 +291,8 @@
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
@@ -303,27 +312,21 @@
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
@@ -347,10 +350,10 @@
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<template #transport><TabPlaceholderBlank /></template>
<template #statistics><TabPlaceholderBlank /></template>
<template #reports><TabPlaceholderBlank /></template>
<template #exchanges><TabPlaceholderBlank /></template>
</MalioTabList>
</template>
@@ -382,7 +385,6 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import {
canEditClient,
categoryOptionsOf,
@@ -428,6 +430,7 @@ import {
import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########'
const EMPLOYEES_MASK = '#######'
@@ -492,11 +495,6 @@ function hydrate(detail: ClientDetail): void {
contacts.value = (detail.contacts ?? []).map(mapContactToDraft)
addresses.value = (detail.addresses ?? []).map(mapAddressToDraft)
ribs.value = (detail.ribs ?? []).map(mapRibToDraft)
// Chaque bloc reste visible meme vide : si une collection est vide, on amorce
// un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canValidate*).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
if (ribs.value.length === 0) ribs.value.push(emptyRib())
// Charge les listes distributeur / courtier si une relation est deja posee.
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
@@ -615,22 +613,6 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
})
}
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
// chaque collection (aligne sur l'index visible). `mapRowError` mappe une 422
// inline et retourne true ; il ne toaste pas, le fallback `showError` reste
// local a l'edition (cf. catch des submits de collection).
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
} = useClientFormErrors()
// ── Bloc principal ───────────────────────────────────────────────────────────
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
@@ -639,6 +621,9 @@ const isMainValid = computed(() => {
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName)
&& filled(main.email)
&& filled(main.phonePrimary)
&& (filled(main.firstName) || filled(main.lastName))
&& main.categoryIris.length >= 1
&& relationValid
})
@@ -658,7 +643,6 @@ async function onRelationChange(value: string | number | null): Promise<void> {
async function submitMain(): Promise<void> {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
headers: { Accept: 'application/ld+json' },
@@ -669,17 +653,7 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
// 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') })
}
showError(e, { duplicateCompany: true })
}
finally {
mainSubmitting.value = false
@@ -691,13 +665,12 @@ async function submitMain(): Promise<void> {
async function submitInformation(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false })
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
informationErrors.handleApiError(e, { fallbackMessage: t('commercial.clients.toast.error') })
showError(e)
}
finally {
tabSubmitting.value = false
@@ -721,9 +694,6 @@ function askRemoveContact(index: number): void {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
}
@@ -735,18 +705,15 @@ function askRemoveContact(index: number): void {
async function submitContacts(): Promise<void> {
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
for (let index = 0; index < contacts.value.length; index++) {
const contact = contacts.value[index]
for (const contact of contacts.value) {
if (!isContactNamed(contact)) continue
const body = buildContactPayload(contact)
try {
if (contact.id === null) {
const created = await api.post<{ '@id'?: string, id: number }>(
`/clients/${clientId}/contacts`,
@@ -760,14 +727,6 @@ async function submitContacts(): Promise<void> {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
}
catch (error) {
// 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe.
if (!mapRowError(error, contactErrors, index)) {
showError(error)
}
return
}
}
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
@@ -783,9 +742,7 @@ const canValidateAddresses = computed(() =>
addresses.value.length > 0
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail)
}),
)
@@ -798,9 +755,6 @@ function askRemoveAddress(index: number): void {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
}
@@ -817,17 +771,14 @@ function onAddressDegraded(): void {
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
for (let index = 0; index < addresses.value.length; index++) {
const address = addresses.value[index]
for (const address of addresses.value) {
const body = buildAddressPayload(address, isBillingEmailRequired(address))
try {
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/addresses`,
@@ -840,13 +791,6 @@ async function submitAddresses(): Promise<void> {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
}
catch (error) {
if (!mapRowError(error, addressErrors, index)) {
showError(error)
}
return
}
}
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
@@ -889,9 +833,6 @@ function askRemoveRib(index: number): void {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
@@ -904,29 +845,17 @@ function askRemoveRib(index: number): void {
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
ribErrors.value = []
try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
// 2) POST/PATCH des RIB (erreurs inline par ligne).
for (let index = 0; index < ribs.value.length; index++) {
const rib = ribs.value[index]
for (const rib of ribs.value) {
if (!ribIsComplete(rib)) continue
const body = buildRibPayload(rib)
try {
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/ribs`,
@@ -939,13 +868,6 @@ async function submitAccounting(): Promise<void> {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
}
catch (error) {
if (!mapRowError(error, ribErrors, index)) {
showError(error)
}
return
}
}
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
@@ -52,23 +52,43 @@
:label="t('commercial.clients.form.main.companyName')"
readonly
/>
<MalioInputText
:model-value="client.lastName"
:label="t('commercial.clients.form.main.lastName')"
readonly
/>
<MalioInputText
:model-value="client.firstName"
:label="t('commercial.clients.form.main.firstName')"
readonly
/>
<MalioSelectCheckbox
:model-value="categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
disabled
/>
<MalioInputPhone
v-for="(phone, index) in mainPhones"
:key="index"
:model-value="phone"
:label="index === 0 ? t('commercial.clients.form.main.phonePrimary') : t('commercial.clients.form.main.phoneSecondary')"
:mask="PHONE_MASK"
readonly
/>
<MalioInputEmail
:model-value="client.email"
:label="t('commercial.clients.form.main.email')"
readonly
/>
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
<MalioSelect
v-if="relation.type"
:model-value="relation.type"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
readonly
disabled
/>
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
aucune valeur sans relation meme comportement qu'en edition). -->
<MalioInputText
v-if="relation.type"
:model-value="relation.name"
@@ -84,7 +104,7 @@
</div>
<!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet 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)]">
@@ -94,7 +114,7 @@
resize="none"
group-class="row-span-2 pt-1"
text-input="h-full text-lg"
readonly
disabled
/>
<MalioInputText
:model-value="information.competitors"
@@ -114,7 +134,7 @@
<MalioInputAmount
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
readonly
disabled
/>
<MalioInputText
:model-value="information.directorName"
@@ -124,7 +144,7 @@
<MalioInputAmount
:model-value="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
readonly
disabled
/>
</div>
</template>
@@ -139,6 +159,9 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
readonly
/>
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyContacts') }}
</p>
</div>
</template>
@@ -151,11 +174,14 @@
:model-value="view.draft"
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
:category-options="view.categoryOptions"
:site-options="allSiteOptions"
:site-options="view.siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
readonly
/>
<p v-if="addressViews.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyAddresses') }}
</p>
</div>
</template>
@@ -163,7 +189,7 @@
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText
:model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
@@ -180,7 +206,7 @@
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label=""
readonly
disabled
/>
<MalioInputText
:model-value="accounting.nTva"
@@ -192,14 +218,14 @@
:options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')"
empty-option-label=""
readonly
disabled
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')"
empty-option-label=""
readonly
disabled
/>
<MalioSelect
v-if="accounting.bankIri"
@@ -207,7 +233,7 @@
:options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')"
empty-option-label=""
readonly
disabled
/>
</div>
</div>
@@ -218,7 +244,7 @@
:key="rib.id ?? index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText
:model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
@@ -240,10 +266,10 @@
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<template #transport><TabPlaceholderBlank /></template>
<template #statistics><TabPlaceholderBlank /></template>
<template #reports><TabPlaceholderBlank /></template>
<template #exchanges><TabPlaceholderBlank /></template>
</MalioTabList>
</template>
@@ -293,9 +319,10 @@ import {
type ClientDetail,
type SelectOption,
} from '~/modules/commercial/utils/clientConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm'
import { formatPhoneFR } from '~/shared/utils/phone'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
// Masques d'affichage (purement visuels, la donnee reste celle du serveur).
const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########'
const { t } = useI18n()
@@ -303,7 +330,6 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est
// redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7.
@@ -328,6 +354,13 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
const relation = computed(() => (client.value ? relationOf(client.value) : { type: null, name: null }))
const categoryIris = computed(() => (client.value?.categories ?? []).map(c => c['@id']))
// Telephones du formulaire principal, formates XX XX XX XX XX (RG d'affichage).
const mainPhones = computed(() =>
[client.value?.phonePrimary, client.value?.phoneSecondary]
.filter((p): p is string => Boolean(p))
.map(formatPhoneFR),
)
const information = computed(() => ({
description: client.value?.description ?? null,
competitors: client.value?.competitors ?? null,
@@ -339,21 +372,10 @@ const information = computed(() => ({
directorName: client.value?.directorName ?? null,
}))
// Chaque bloc reste visible meme vide en consultation : si la collection est
// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun … »).
const contacts = computed(() => {
const list = (client.value?.contacts ?? []).map(mapContactToDraft)
return list.length ? list : [emptyContact()]
})
const contacts = computed(() => (client.value?.contacts ?? []).map(mapContactToDraft))
// Vue par adresse : brouillon + options (sites/categories) propres a l'adresse.
const addressViews = computed(() => {
const views = (client.value?.addresses ?? []).map(mapAddressView)
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
})
const ribs = computed(() => {
const list = (client.value?.ribs ?? []).map(mapRibToDraft)
return list.length ? list : [emptyRib()]
})
const addressViews = computed(() => (client.value?.addresses ?? []).map(mapAddressView))
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -363,18 +385,6 @@ const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as Clie
const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories))
const contactOptions = computed(() => contactOptionsOf(client.value?.contacts))
// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read — donc
// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero
// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS
// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non
// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris).
const allSiteOptions = computed<SelectOption[]>(() =>
(authStore.user?.sites ?? []).map(s => ({
value: `/api/sites/${s.id}`,
label: (s.postalCode ?? '').slice(0, 2),
})),
)
const relationOptions = computed<SelectOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
+120 -145
View File
@@ -22,24 +22,50 @@
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioInputText
v-model="main.lastName"
:label="t('commercial.clients.form.main.lastName')"
:readonly="mainLocked"
/>
<MalioInputText
v-model="main.firstName"
:label="t('commercial.clients.form.main.firstName')"
:readonly="mainLocked"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
:disabled="mainLocked"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<!-- Telephones : 1 par defaut, le bouton « + » revele le 2e (max 2, RG-1.02). -->
<MalioInputPhone
v-for="(_, index) in mainPhones"
:key="index"
v-model="mainPhones[index]"
:label="t('commercial.clients.form.main.phonePrimary')"
:mask="PHONE_MASK"
:required="index === 0"
:readonly="mainLocked"
add-icon-name="mdi:plus"
:addable="mainPhones.length === 1 && !mainLocked"
:add-button-label="t('commercial.clients.form.main.addPhone')"
@add="addMainPhone"
/>
<MalioInputEmail
v-model="main.email"
:label="t('commercial.clients.form.main.email')"
:required="true"
:readonly="mainLocked"
/>
<MalioSelect
:model-value="main.relationType"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="mainLocked"
:disabled="mainLocked"
@update:model-value="onRelationChange"
/>
<MalioSelect
@@ -47,9 +73,7 @@
:model-value="main.brokerIri"
:options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.broker"
:disabled="mainLocked"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/>
<MalioSelect
@@ -57,9 +81,7 @@
:model-value="main.distributorIri"
:options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.distributor"
:disabled="mainLocked"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/>
<MalioCheckbox
@@ -92,45 +114,38 @@
resize="none"
group-class="row-span-2 pt-1"
text-input="h-full text-lg"
:readonly="isValidated('information')"
:error="informationErrors.errors.description"
:disabled="isValidated('information')"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')"
:readonly="isValidated('information')"
:error="informationErrors.errors.competitors"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
:readonly="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
:disabled="isValidated('information')"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')"
:readonly="isValidated('information')"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
:readonly="isValidated('information')"
:error="informationErrors.errors.profitAmount"
:disabled="isValidated('information')"
/>
</div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
@@ -156,7 +171,6 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="index > 0"
:readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
@@ -193,7 +207,6 @@
:country-options="countryOptions"
:removable="index > 0"
:readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
@@ -220,57 +233,45 @@
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
@@ -278,10 +279,8 @@
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
@@ -302,27 +301,21 @@
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
@@ -348,7 +341,7 @@
<!-- Onglet non encore implemente : frame vide, passage automatique.
Statistiques / Rapports / Echanges sont edit-only (absents a la
creation) — cf. buildClientFormTabKeys. -->
<template #transport><ComingSoonPlaceholder /></template>
<template #transport><TabPlaceholderBlank /></template>
</MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
@@ -378,7 +371,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import {
buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS,
@@ -396,9 +388,11 @@ import {
type ContactFormDraft,
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { formatPhoneFR } from '~/shared/utils/phone'
import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########'
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
const EMPLOYEES_MASK = '#######'
@@ -428,22 +422,6 @@ function apiErrorMessage(error: unknown): string {
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') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
@@ -466,6 +444,9 @@ const tabSubmitting = ref(false)
// ── Formulaire principal ────────────────────────────────────────────────────
const main = reactive({
companyName: null as string | null,
firstName: null as string | null,
lastName: null as string | null,
email: null as string | null,
categoryIris: [] as string[],
relationType: null as 'distributeur' | 'courtier' | null,
distributorIri: null as string | null,
@@ -473,6 +454,17 @@ const main = reactive({
triageService: false,
})
// Telephones du formulaire principal : 1 par defaut, 2 au maximum (RG-1.02).
// L'index 0 alimente phonePrimary, l'index 1 phoneSecondary au POST.
const mainPhones = ref<string[]>([''])
/** Revele le 2e numero (le bouton « + » disparait une fois a 2, RG-1.02). */
function addMainPhone(): void {
if (mainPhones.value.length === 1) {
mainPhones.value.push('')
}
}
// Pas d'option « Aucun » : le select est vide par defaut (relationType = null).
const relationOptions = computed<RefOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
@@ -480,11 +472,10 @@ const relationOptions = computed<RefOption[]>(() => [
])
// Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / >= 1 categorie obligatoires ;
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant
// devient requis si l'un des deux est choisi (spec fonctionnelle).
// Les coordonnees de contact ne sont plus saisies ici : elles vivent dans
// l'onglet Contacts (RG-1.05/1.14 garantissent >= 1 contact valide).
// - companyName / email / telephone principal / >= 1 categorie obligatoires ;
// - RG-1.01 : nom OU prenom du contact principal ;
// - relation Distributeur/Courtier obligatoire (un des deux), ET le nom
// correspondant obligatoire selon le choix (spec fonctionnelle).
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
@@ -494,6 +485,9 @@ const isMainValid = computed(() => {
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName)
&& filled(main.email)
&& filled(mainPhones.value[0])
&& (filled(main.firstName) || filled(main.lastName))
&& main.categoryIris.length >= 1
&& relationValid
})
@@ -515,10 +509,14 @@ async function onRelationChange(value: string | number | null): Promise<void> {
async function submitMain(): Promise<void> {
if (!isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const payload: Record<string, unknown> = {
companyName: main.companyName,
firstName: main.firstName || null,
lastName: main.lastName || null,
email: main.email,
phonePrimary: mainPhones.value[0] || null,
phoneSecondary: mainPhones.value[1] || null,
categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
@@ -530,8 +528,18 @@ async function submitMain(): Promise<void> {
})
clientId.value = created.id
// Reaffiche la valeur normalisee renvoyee par le serveur.
// Reaffiche les valeurs normalisees renvoyees par le serveur.
main.companyName = created.companyName ?? main.companyName
main.firstName = created.firstName ?? null
main.lastName = created.lastName ?? null
main.email = created.email ?? main.email
// Reaffiche les telephones normalises (reformates via formatPhoneFR).
const normalizedPhones = [formatPhoneFR(created.phonePrimary), formatPhoneFR(created.phoneSecondary)]
.filter(p => p !== '')
mainPhones.value = normalizedPhones.length > 0 ? normalizedPhones : ['']
// Pre-remplit le 1er contact a partir du formulaire principal (editable).
prefillFirstContact()
mainLocked.value = true
unlockedIndex.value = 0
@@ -539,18 +547,15 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.createSuccess') })
}
catch (error) {
// 409 = doublon nom de societe (RG d'unicite) → erreur inline sur le
// champ + toast explicite ; 422 → mapping inline par champ (pas de
// toast) ; autre → toast de fallback. Cf. ERP-101.
// 409 = doublon nom de societe (RG d'unicite) → message explicite ;
// sinon on remonte le message de validation du serveur (ex: 422).
const status = (error 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(error, { fallbackMessage: t('commercial.clients.toast.error') })
}
toast.error({
title: t('commercial.clients.toast.error'),
message: status === 409
? t('commercial.clients.form.duplicateCompany')
: apiErrorMessage(error),
})
}
finally {
mainSubmitting.value = false
@@ -625,7 +630,6 @@ const information = reactive({
async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
await api.patch(`/clients/${clientId.value}`, {
description: information.description || null,
@@ -640,7 +644,7 @@ async function submitInformation(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (error) {
informationErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false
@@ -648,10 +652,18 @@ async function submitInformation(): Promise<void> {
}
// ── Onglet Contact ──────────────────────────────────────────────────────────
// Au moins un bloc Contact vide au depart : c'est desormais le seul point de
// saisie des coordonnees (le bloc principal ne porte plus de contact inline).
const contacts = ref<ContactFormDraft[]>([emptyContact()])
/** Pre-remplit le 1er contact depuis le formulaire principal (apres creation). */
function prefillFirstContact(): void {
const first = contacts.value[0]
if (!first) return
first.lastName = main.lastName
first.firstName = main.firstName
first.email = main.email
first.phonePrimary = mainPhones.value[0] ?? null
}
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
@@ -668,7 +680,6 @@ function addContact(): void {
function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
})
}
@@ -676,10 +687,8 @@ function askRemoveContact(index: number): void {
async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (let index = 0; index < contacts.value.length; index++) {
const contact = contacts.value[index]
for (const contact of contacts.value) {
// On ignore les blocs totalement vides (ni nom ni prenom).
if (!isContactNamed(contact)) continue
@@ -692,7 +701,6 @@ async function submitContacts(): Promise<void> {
email: contact.email || null,
}
try {
if (contact.id === null) {
const created = await api.post<ContactResponse>(
`/clients/${clientId.value}/contacts`,
@@ -706,18 +714,12 @@ async function submitContacts(): Promise<void> {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
}
catch (error) {
// 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe
// a la premiere ligne en echec (les suivantes ne sont pas tentees).
if (!mapRowError(error, contactErrors, index)) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return
}
}
completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false
}
@@ -753,9 +755,7 @@ const canValidateAddresses = computed(() =>
addresses.value.length > 0
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail)
}),
)
@@ -766,7 +766,6 @@ function addAddress(): void {
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
})
}
@@ -784,10 +783,8 @@ function onAddressDegraded(): void {
async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (let index = 0; index < addresses.value.length; index++) {
const address = addresses.value[index]
for (const address of addresses.value) {
const body = {
isProspect: address.isProspect,
isDelivery: address.isDelivery,
@@ -803,7 +800,6 @@ async function submitAddresses(): Promise<void> {
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
}
try {
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`,
@@ -816,16 +812,12 @@ async function submitAddresses(): Promise<void> {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
}
catch (error) {
if (!mapRowError(error, addressErrors, index)) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return
}
}
completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false
}
@@ -878,9 +870,6 @@ function addRib(): void {
function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce au montage).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
@@ -892,10 +881,6 @@ function askRemoveRib(index: number): void {
async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
ribErrors.value = []
try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null,
@@ -906,17 +891,9 @@ async function submitAccounting(): Promise<void> {
paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
// 2) POST/PATCH des RIB (erreurs inline par ligne).
for (let index = 0; index < ribs.value.length; index++) {
const rib = ribs.value[index]
for (const rib of ribs.value) {
if (!ribIsComplete(rib)) continue
try {
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
@@ -929,17 +906,13 @@ async function submitAccounting(): Promise<void> {
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
}
}
catch (error) {
if (!mapRowError(error, ribErrors, index)) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return
}
}
completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally {
tabSubmitting.value = false
}
@@ -968,6 +941,11 @@ function runConfirm(): void {
interface ClientResponse {
id: number
companyName: string | null
firstName: string | null
lastName: string | null
email: string | null
phonePrimary: string | null
phoneSecondary: string | null
}
interface ContactResponse {
@@ -978,8 +956,5 @@ interface ContactResponse {
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadCommon().catch(() => {})
// Au moins un bloc RIB toujours visible en creation : on amorce un bloc vide
// (non persiste tant qu'incomplet — RG-1.13).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
</script>
@@ -22,6 +22,12 @@ import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules
function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft {
return {
companyName: 'ACME',
firstName: 'Jean',
lastName: 'Dupont',
email: 'jean@acme.fr',
phonePrimary: '05 49 11 22 33',
phoneSecondary: null,
hasSecondaryPhone: false,
categoryIris: ['/api/categories/1'],
relationType: null,
distributorIri: null,
@@ -58,10 +64,9 @@ function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): Accounti
}
// Champs de chaque groupe de serialisation (miroir back ClientProcessor).
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
const MAIN_KEYS = [
'companyName', 'categories', 'distributor', 'broker', 'triageService',
'companyName', 'firstName', 'lastName', 'email', 'phonePrimary',
'phoneSecondary', 'categories', 'distributor', 'broker', 'triageService',
]
const INFORMATION_KEYS = [
'description', 'competitors', 'foundedAt', 'employeesCount',
@@ -99,6 +104,11 @@ describe('buildMainPayload — scoping strict groupe client:write:main', () => {
expect(payload.distributor).toBeNull()
expect(payload.broker).toBeNull()
})
it('telephone secondaire non revele : envoie null meme si une valeur traine', () => {
const payload = buildMainPayload(mainDraft({ hasSecondaryPhone: false, phoneSecondary: '06 00 00 00 00' }))
expect(payload.phoneSecondary).toBeNull()
})
})
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
@@ -158,16 +168,19 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
})
describe('mapMainDraft — pre-remplissage bloc principal', () => {
it('resout la relation et extrait les IRI (sans contact inline)', () => {
it('formate les telephones, resout la relation et extrait les IRI', () => {
const client = {
'@id': '/api/clients/1', id: 1,
companyName: 'ACME', triageService: true,
companyName: 'ACME', firstName: 'Jean', lastName: 'Dupont', email: 'jean@acme.fr',
phonePrimary: '0549112233', phoneSecondary: '0600000000', triageService: true,
categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }],
distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' },
} as ClientDetail
const draft = mapMainDraft(client)
expect(draft.companyName).toBe('ACME')
expect(draft.phonePrimary).toBe('05 49 11 22 33')
expect(draft.phoneSecondary).toBe('06 00 00 00 00')
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.categoryIris).toEqual(['/api/categories/1'])
expect(draft.relationType).toBe('distributeur')
expect(draft.distributorIri).toBe('/api/clients/9')
@@ -178,6 +191,7 @@ describe('mapMainDraft — pre-remplissage bloc principal', () => {
it('gere les cles omises (skip_null_values) sans planter', () => {
const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail)
expect(draft.companyName).toBeNull()
expect(draft.hasSecondaryPhone).toBe(false)
expect(draft.categoryIris).toEqual([])
expect(draft.relationType).toBeNull()
expect(draft.triageService).toBe(false)
@@ -93,6 +93,11 @@ export interface RelatedClientRead extends HydraRef {
export interface ClientDetail extends HydraRef {
id: number
companyName?: string | null
firstName?: string | null
lastName?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
triageService?: boolean
isArchived?: boolean
categories?: CategoryRead[]
@@ -24,16 +24,23 @@ import {
type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
import { formatPhoneFR } from '~/shared/utils/phone'
/**
* Etat « plat » du bloc principal (groupe client:write:main). Distinct des
* brouillons Contact : ces champs vivent sur le Client lui-meme (companyName,
* categories, relation, triage), pas sur une sous-ressource ClientContact. Les
* coordonnees de contact (nom, prenom, telephones, email) ne sont plus portees
* par le Client : elles vivent exclusivement dans l'onglet Contacts.
* contact principal, telephones, email, categories, relation, triage), pas sur
* une sous-ressource ClientContact.
*/
export interface MainFormDraft {
companyName: string | null
firstName: string | null
lastName: string | null
email: string | null
phonePrimary: string | null
phoneSecondary: string | null
/** UI : le 2e numero a ete revele (ou existait deja au chargement). */
hasSecondaryPhone: boolean
/** IRI des categories rattachees (M2M). */
categoryIris: string[]
relationType: 'distributeur' | 'courtier' | null
@@ -89,15 +96,22 @@ export interface TabEditability {
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
/**
* Mappe le detail client vers le brouillon du bloc principal. La relation
* Distributeur/Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait
* de l'embed.
* Mappe le detail client vers le brouillon du bloc principal. Les telephones
* sont reformates XX XX XX XX XX (RG d'affichage). La relation Distributeur/
* Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait de l'embed.
*/
export function mapMainDraft(client: ClientDetail): MainFormDraft {
const relation = relationOf(client)
const phoneSecondary = client.phoneSecondary ?? null
return {
companyName: client.companyName ?? null,
firstName: client.firstName ?? null,
lastName: client.lastName ?? null,
email: client.email ?? null,
phonePrimary: client.phonePrimary ? formatPhoneFR(client.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
categoryIris: (client.categories ?? []).map(c => c['@id']),
relationType: relation.type,
distributorIri: iriOf(client.distributor),
@@ -143,6 +157,11 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
return {
companyName: main.companyName,
firstName: main.firstName || null,
lastName: main.lastName || null,
email: main.email,
phonePrimary: main.phonePrimary || null,
phoneSecondary: main.hasSecondaryPhone ? (main.phoneSecondary || null) : null,
categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.4",
"@malio/layer-ui": "^1.7.3",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.4",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.4/layer-ui-1.7.4.tgz",
"integrity": "sha512-JNXwBelj5UQ35Qv5VmnassXKt8niX9jDXjM1vUSukJQiyeUXRxAiZr16QumVgBN9P9YGDyjXVKrwCHltTXvPtQ==",
"version": "1.7.3",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz",
"integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.7.4",
"@malio/layer-ui": "^1.7.3",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

@@ -1,51 +0,0 @@
<template>
<!--
Placeholder generique « En cours de dev » pour les ecrans / onglets non
encore implementes. Composant PARTAGE (shared/components) : auto-importe
sans prefixe (`<ComingSoonPlaceholder>`) et reutilisable depuis n'importe
quel module. Affiche un gif (asset local par defaut) + un message i18n.
-->
<div class="flex min-h-[240px] flex-col items-center justify-center gap-4 rounded-md bg-white py-10">
<img
v-if="!imageFailed"
:src="src"
:alt="resolvedTitle"
class="max-h-[220px] w-auto rounded-md"
@error="imageFailed = true"
>
<!-- Repli si le gif ne charge pas (offline, CSP, asset absent) :
illustration emoji, le message reste affiche. -->
<div v-else class="text-5xl" aria-hidden="true">🚧 👨‍💻 🚧</div>
<div class="text-center">
<p class="text-xl font-bold text-black">{{ resolvedTitle }}</p>
<p class="mt-1 text-black/60">{{ resolvedSubtitle }}</p>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
/** Source de l'image/gif affichee. Defaut : asset local `/coming-soon.gif`. */
src?: string
/** Titre. Defaut : i18n `common.comingSoon.title`. */
title?: string
/** Sous-titre. Defaut : i18n `common.comingSoon.subtitle`. */
subtitle?: string
}>(),
{
src: '/coming-soon.gif',
title: '',
subtitle: '',
},
)
const { t } = useI18n()
const imageFailed = ref(false)
// Les props priment sur les libelles i18n par defaut (permet a un module
// d'override le texte sans toucher au composant).
const resolvedTitle = computed(() => props.title || t('common.comingSoon.title'))
const resolvedSubtitle = computed(() => props.subtitle || t('common.comingSoon.subtitle'))
</script>
@@ -1,132 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
useAddressAutocomplete,
AddressAutocompleteUnavailableError,
} from '../useAddressAutocomplete'
// On mocke le helper d'appel externe : aucun vrai appel reseau a la BAN.
// vi.mock est hoiste par Vitest au-dessus des imports.
const mockHttp = vi.hoisted(() => vi.fn())
vi.mock('~/shared/utils/httpExternal', () => ({ httpExternal: mockHttp }))
const BAN_URL = 'https://api-adresse.data.gouv.fr/search/'
describe('useAddressAutocomplete', () => {
beforeEach(() => {
mockHttp.mockReset()
})
describe('searchCity', () => {
it('interroge la BAN en type=municipality et mappe { city, postalCode }', async () => {
mockHttp.mockResolvedValueOnce({
type: 'FeatureCollection',
features: [
{ properties: { city: 'Amiens', postcode: '80000', name: 'Amiens', type: 'municipality' } },
{ properties: { city: 'Amiens', postcode: '80080', name: 'Amiens', type: 'municipality' } },
],
})
const { searchCity } = useAddressAutocomplete()
const res = await searchCity('80000')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({ query: { q: '80000', type: 'municipality' } }),
)
expect(res).toEqual([
{ city: 'Amiens', postalCode: '80000' },
{ city: 'Amiens', postalCode: '80080' },
])
})
it('throw une AddressAutocompleteUnavailableError sur erreur reseau / 5xx', async () => {
mockHttp.mockRejectedValueOnce(new Error('500 Server Error'))
const { searchCity } = useAddressAutocomplete()
await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError)
})
it('throw une AddressAutocompleteUnavailableError sur timeout', async () => {
mockHttp.mockRejectedValueOnce(new Error('The operation was aborted due to timeout'))
const { searchCity } = useAddressAutocomplete()
await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError)
})
})
describe('searchAddress', () => {
it('interroge la BAN avec postcode et mappe la suggestion', async () => {
mockHttp.mockResolvedValueOnce({
type: 'FeatureCollection',
features: [
{
properties: {
label: '8 Boulevard du Port 80000 Amiens',
name: '8 Boulevard du Port',
street: 'Boulevard du Port',
postcode: '80000',
city: 'Amiens',
type: 'housenumber',
},
},
],
})
const { searchAddress } = useAddressAutocomplete()
const res = await searchAddress('8 boulevard du port', '80000')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({
query: { q: '8 boulevard du port', postcode: '80000' },
}),
)
expect(res).toEqual([
{
label: '8 Boulevard du Port 80000 Amiens',
street: '8 Boulevard du Port',
postalCode: '80000',
city: 'Amiens',
},
])
})
it('omet le parametre postcode quand aucun code postal n\'est fourni', async () => {
mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] })
const { searchAddress } = useAddressAutocomplete()
await searchAddress('8 boulevard du port')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({
query: { q: '8 boulevard du port' },
}),
)
})
it('ne restreint PAS la recherche a type=housenumber (sinon la BAN ne renvoie rien tant qu\'aucun numero n\'est saisi)', async () => {
// Regression : avec `type=housenumber`, une saisie de nom de rue sans
// numero (ex: « boulevard du port ») renvoie 0 resultat cote BAN.
mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] })
const { searchAddress } = useAddressAutocomplete()
await searchAddress('boulevard du port', '80000')
const sentQuery = mockHttp.mock.calls[0]?.[1]?.query as Record<string, string>
expect(sentQuery.type).toBeUndefined()
})
it('throw une AddressAutocompleteUnavailableError sur erreur reseau', async () => {
mockHttp.mockRejectedValueOnce(new Error('network down'))
const { searchAddress } = useAddressAutocomplete()
await expect(searchAddress('8 boulevard du port', '80000')).rejects.toBeInstanceOf(
AddressAutocompleteUnavailableError,
)
})
})
})
@@ -1,91 +0,0 @@
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)
})
})
@@ -1,29 +1,27 @@
import { httpExternal } from '~/shared/utils/httpExternal'
// Autocompletion d'adresse branchee sur la Base Adresse Nationale (BAN),
// `api-adresse.data.gouv.fr` — service public francais, gratuit, CORS ouvert.
// STUB ERP-63 — remplacé par l'implémentation BAN d'ERP-66.
//
// Appel HTTP DIRECT depuis le front (pas de proxy back), conformement a la spec
// M1 (§ API adresse postale). On passe par `httpExternal` et NON `useApi()` :
// la BAN est un domaine externe, sans cookie de session ni enveloppe Hydra.
// Ce fichier appartient fonctionnellement à ERP-66 (#66). ERP-63 n'en livre
// qu'un STUB pour ne pas se bloquer : la vraie implémentation (appels
// api-adresse.data.gouv.fr) viendra remplacer le CORPS des deux méthodes SANS
// changer leur signature ni l'usage côté composant.
//
// Contrat (fige) :
// Contrat figé par ERP-66 (c'est lui qui fait foi) :
// searchCity(postalCode) -> liste { city, postalCode }
// searchAddress(query, cp?) -> liste { label, street, postalCode, city }
// En cas d'erreur/timeout, la methode THROW une AddressAutocompleteUnavailableError.
// Le composant consommateur catch, affiche un toast d'avertissement et bascule
// en saisie libre (MalioInputText).
// En cas d'erreur/timeout, la méthode THROW. Le composant catch l'erreur,
// affiche un toast d'avertissement et bascule en saisie libre (MalioInputText).
//
// Comportement du stub : les deux méthodes throw systématiquement → l'onglet
// Adresse part directement en mode dégradé (Ville + Adresse en saisie libre,
// Code postal saisi manuellement). Aucun appel réseau n'est émis ici.
/** URL de l'endpoint de recherche BAN. */
const BAN_SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/'
/** Une suggestion de ville renvoyee a partir d'un code postal. */
/** Une suggestion de ville renvoyée à partir d'un code postal. */
export interface CitySuggestion {
city: string
postalCode: string
}
/** Une suggestion d'adresse complete (saisie assistee du champ « Adresse »). */
/** Une suggestion d'adresse complète (saisie assistée du champ « Adresse »). */
export interface AddressSuggestion {
label: string
street: string
@@ -36,82 +34,27 @@ export interface AddressAutocomplete {
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
}
/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */
/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */
export class AddressAutocompleteUnavailableError extends Error {
constructor() {
// Message technique (non affiche tel quel) : le composant remonte son
// propre libelle i18n. Sert au debug / aux logs uniquement.
super('Address autocomplete (BAN) is not available.')
// Message technique (non affiché tel quel) : le composant remonte son
// propre libellé i18n. Sert au debug / aux logs uniquement.
super('Address autocomplete (BAN) is not available yet — ERP-66 stub.')
this.name = 'AddressAutocompleteUnavailableError'
}
}
/** Proprietes d'une « feature » GeoJSON renvoyee par la BAN (champs utilises). */
interface BanFeatureProperties {
label?: string
name?: string
street?: string
postcode?: string
city?: string
}
/** Reponse GeoJSON FeatureCollection de la BAN. */
interface BanResponse {
features?: { properties?: BanFeatureProperties }[]
}
/**
* STUB : renvoie un composable conforme au contrat ERP-66 dont les méthodes
* échouent toujours, forçant le mode dégradé côté onglet Adresse.
*/
export function useAddressAutocomplete(): AddressAutocomplete {
return {
async searchCity(postalCode: string): Promise<CitySuggestion[]> {
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
query: { q: postalCode, type: 'municipality' },
})
} catch {
// Reseau coupe, 5xx, timeout... -> mode degrade cote composant.
async searchCity(_postalCode: string): Promise<CitySuggestion[]> {
throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
city: props.city ?? props.name ?? '',
postalCode: props.postcode ?? '',
}
})
},
async searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> {
// IMPORTANT : pas de `type=housenumber` ici. La BAN ne renvoie un
// resultat de ce type qu'une fois un numero saisi → une recherche par
// nom de rue (« boulevard du port ») renverrait 0 resultat pendant
// toute la frappe. Sans filtre `type`, la BAN classe rues + numeros
// par pertinence (comportement d'autocompletion attendu).
// On n'ajoute `postcode` que s'il est fourni (sinon recherche large).
const banQuery: Record<string, string> = { q: query }
if (postalCode) {
banQuery.postcode = postalCode
}
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, { query: banQuery })
} catch {
async searchAddress(_query: string, _postalCode?: string): Promise<AddressSuggestion[]> {
throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
label: props.label ?? '',
// `name` porte la ligne d'adresse complete (numero + voie) ;
// `street` ne contient que la voie. On privilegie `name`.
street: props.name ?? props.street ?? '',
postalCode: props.postcode ?? '',
city: props.city ?? '',
}
})
},
}
}
+5 -5
View File
@@ -44,7 +44,7 @@ export function useApi(): ApiClient {
const data = responseData ?? (error as FetchError)?.data
const msg = extractApiErrorMessage(data)
if (msg) return msg
return (error as FetchError)?.message ?? t('errors.unknown')
return (error as FetchError)?.message ?? 'Erreur inconnue.'
}
const methodErrorKeys: Record<string, string> = {
@@ -76,7 +76,7 @@ export function useApi(): ApiClient {
if (successMessage) {
toast.success({
title: t('success.title'),
title: 'Succes',
message: successMessage
})
}
@@ -98,10 +98,10 @@ export function useApi(): ApiClient {
apiOptions?.toastErrorMessage ||
errorMessage ||
extractedMessage ||
t('errors.generic')
'Une erreur est survenue.'
toast.error({
title: apiOptions?.toastTitle ?? t('errors.title'),
title: apiOptions?.toastTitle ?? 'Erreur',
message
})
}
@@ -139,7 +139,7 @@ export function useApi(): ApiClient {
'Une erreur est survenue.'
toast.error({
title: apiOptions?.toastTitle ?? t('errors.title'),
title: apiOptions?.toastTitle ?? 'Erreur',
message
})
}
@@ -1,113 +0,0 @@
/**
* 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,
}
}
@@ -1,58 +0,0 @@
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.' })
})
})
@@ -1,56 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { httpExternal } from '../httpExternal'
// On mocke ofetch : httpExternal s'appuie sur $fetch sans jamais toucher le
// reseau pendant les tests. vi.mock est hoiste par Vitest au-dessus des imports.
const mockFetch = vi.hoisted(() => vi.fn())
vi.mock('ofetch', () => ({ $fetch: mockFetch }))
describe('httpExternal', () => {
beforeEach(() => {
mockFetch.mockReset()
})
it('retourne le JSON parse renvoye par $fetch', async () => {
mockFetch.mockResolvedValueOnce({ ok: true })
const res = await httpExternal<{ ok: boolean }>('https://example.test/api')
expect(res).toEqual({ ok: true })
})
it('transmet la query, coupe le cookie (credentials omit) et pose un timeout par defaut', async () => {
mockFetch.mockResolvedValueOnce([])
await httpExternal('https://example.test/search', {
query: { q: '80000', type: 'municipality' },
})
expect(mockFetch).toHaveBeenCalledWith(
'https://example.test/search',
expect.objectContaining({
query: { q: '80000', type: 'municipality' },
credentials: 'omit',
retry: 0,
timeout: 5000,
}),
)
})
it('permet de surcharger le timeout', async () => {
mockFetch.mockResolvedValueOnce(null)
await httpExternal('https://example.test', { timeoutMs: 1000 })
expect(mockFetch).toHaveBeenCalledWith(
'https://example.test',
expect.objectContaining({ timeout: 1000 }),
)
})
it('propage l\'erreur reseau / timeout (throw)', async () => {
mockFetch.mockRejectedValueOnce(new Error('network down'))
await expect(httpExternal('https://example.test')).rejects.toThrow('network down')
})
})
@@ -20,27 +20,4 @@ describe('formatPhoneFR', () => {
it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => {
expect(formatPhoneFR('123')).toBe('12 3')
})
it('formate une saisie courte (<= 4 chiffres) sans planter', () => {
expect(formatPhoneFR('1')).toBe('1')
expect(formatPhoneFR('12')).toBe('12')
expect(formatPhoneFR('1234')).toBe('12 34')
})
it('strip les caracteres non numeriques (lettres, espaces, ponctuation)', () => {
expect(formatPhoneFR('abc')).toBe('')
expect(formatPhoneFR('Tel : 06.12')).toBe('06 12')
expect(formatPhoneFR(' 06 12 ')).toBe('06 12')
})
it('conserve l\'indicatif international (+33) sans le transformer', () => {
// Comportement fige : on retire seulement le `+`, on ne deduit pas le
// prefixe pays. Le `+33...` est donc groupe brut par paquets de 2.
expect(formatPhoneFR('+33612345678')).toBe('33 61 23 45 67 8')
})
it('groupe sans tronquer une saisie plus longue que 10 chiffres', () => {
// Aucune troncature silencieuse : on figure tous les chiffres groupes par 2.
expect(formatPhoneFR('061234567899')).toBe('06 12 34 56 78 99')
})
})
-19
View File
@@ -66,25 +66,6 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
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
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
-40
View File
@@ -1,40 +0,0 @@
import { $fetch } from 'ofetch'
/**
* Options d'un appel HTTP externe.
*/
export interface HttpExternalOptions {
/** Parametres de query string (encodes par ofetch). */
query?: Record<string, string | number | undefined>
/** Timeout en millisecondes avant abandon (defaut 5000). */
timeoutMs?: number
}
/**
* Petit client HTTP pour les APIs PUBLIQUES EXTERNES (domaine tiers, hors `/api`).
*
* Pourquoi un helper dedie plutot que `useApi()` : `useApi()` est le client de
* l'API interne Starseed (baseURL `/api`, cookie JWT `credentials: 'include'`,
* parsing/erreurs Hydra, redirection `/login` sur 401, toasts i18n). Tout cela
* est inadapte — voire indesirable — pour un endpoint public externe comme la
* Base Adresse Nationale (`api-adresse.data.gouv.fr`).
*
* Ce helper est donc le SEUL point d'entree autorise pour un `$fetch` brut vers
* l'externe (cf. regle frontend n°4 : pas de `$fetch` eparpille dans les
* composants). Il :
* - cible une URL absolue (pas de baseURL `/api`) ;
* - n'envoie PAS le cookie de session (`credentials: 'omit'`) ;
* - ne retente pas (`retry: 0`) et applique un timeout ;
* - laisse remonter l'erreur (throw) — au consommateur de gerer le mode degrade.
*/
export async function httpExternal<T>(
url: string,
opts: HttpExternalOptions = {},
): Promise<T> {
return $fetch<T>(url, {
query: opts.query,
credentials: 'omit',
retry: 0,
timeout: opts.timeoutMs ?? 5000,
})
}
@@ -177,14 +177,12 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])]
private Collection $contacts;
// Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse).
// RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'client_address_category')]
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['client_address:read', 'client_address:write'])]
private Collection $categories;
@@ -169,7 +169,6 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Non Billing Empty Email');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
@@ -180,7 +179,6 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
@@ -288,29 +286,6 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(201);
}
/**
* Spec-front § Adresse : au moins une categorie est obligatoire sur une
* adresse. POST sans categorie (mais avec site) -> 422.
*/
public function testAddressRequiresAtLeastOneCategory(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address No Cat');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
self::assertResponseStatusCodeSame(422);
}
/**
* Retourne l'IRI du premier site seede (fixtures Sites).
*/
@@ -113,7 +113,6 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Host');
$siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR');
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
@@ -124,7 +123,6 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$siteIri],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();