Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e85d46a17b | |||
| ec952896ba | |||
| 468894cfad | |||
| 912280d24e | |||
| f406a598eb | |||
| a72a5dd812 | |||
| 3dc98994f5 | |||
| 96ddd15c86 | |||
| 1b924ba0fd | |||
| 8fae987e15 | |||
| 6f977d387d | |||
| 1888b70623 | |||
| 1961bc62c8 | |||
| bc7c8f6f83 | |||
| 7833ff32e6 | |||
| 6fee9f6bd6 | |||
| 276f242b10 | |||
| 97301dcd6c | |||
| daeb8b3003 | |||
| 9c311cb58b | |||
| 5a33815584 | |||
| 052a39092b | |||
| 19148800ba | |||
| fc063c725d | |||
| 583d634a83 | |||
| ee1521384e | |||
| 79dffccc79 | |||
| 1ff335b3fe | |||
| fa47517028 | |||
| 402c83d40d | |||
| 50e6e14b91 | |||
| 00bd02858c | |||
| a668a8eb28 | |||
| a52e3bec34 | |||
| 865180e648 | |||
| 0e3299300f | |||
| 8d50f1fbe7 | |||
| 120058049c | |||
| 9507664bd0 | |||
| 0c9b563cae | |||
| b495e4030a | |||
| 56cf492dcc | |||
| d9023ec9b9 | |||
| ddf874a4e1 | |||
| a25ddac466 | |||
| df8e44fcfa | |||
| 5bdd63cc6c | |||
| ad20d1f4c9 | |||
| 0c6919201e | |||
| 3e46394be1 | |||
| 1d91b4dea9 | |||
| c402418937 | |||
| 2866fb8865 | |||
| 0ed131ce57 | |||
| a948eed9b6 | |||
| fc78f434d1 | |||
| 53e19d61ac | |||
| ece8146c03 | |||
| 58589e93d0 | |||
| e0d59962d6 | |||
| 3ce40a707f | |||
| 9613857650 | |||
| 2a0918bbfe | |||
| 8e31e1759c | |||
| 4824690923 | |||
| fceb1e0e83 | |||
| adda62c1e1 | |||
| 80fabcae91 | |||
| ff6086bc4d | |||
| d01bbfbc65 | |||
| 92a6343b66 | |||
| 02df221a0b | |||
| 6efe7aa8ea | |||
| 6c27ac8640 | |||
| 2ef344e22f |
@@ -13,6 +13,64 @@
|
|||||||
- Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`)
|
- Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`)
|
||||||
- **Exception** : si tu dois creer un controller custom sous `/api/`, mettre `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
|
- **Exception** : si tu dois creer un controller custom sous `/api/`, mettre `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
|
||||||
|
|
||||||
|
## Pagination (obligatoire)
|
||||||
|
|
||||||
|
**Regle** : toute collection API DOIT etre paginee. Aucun retour de collection complete cote serveur.
|
||||||
|
|
||||||
|
### Standard global
|
||||||
|
|
||||||
|
Pose dans `config/packages/api_platform.yaml` (section `defaults:`) et heritee par toutes les ressources :
|
||||||
|
|
||||||
|
| Cle | Valeur | Effet |
|
||||||
|
|---|---|---|
|
||||||
|
| `pagination_enabled` | `true` | Pagination Hydra active par defaut. |
|
||||||
|
| `pagination_items_per_page` | `10` | Taille de page par defaut, aligne sur l'UI `MalioDataTable`. |
|
||||||
|
| `pagination_maximum_items_per_page` | `50` | Borne dure : `?itemsPerPage=999` → ramene a 50. Anti deep-fetch. |
|
||||||
|
| `pagination_client_items_per_page` | `true` | Le client peut envoyer `?itemsPerPage=25` (bornee par le max). |
|
||||||
|
| `pagination_client_enabled` | `true` | Le client peut envoyer `?pagination=false` pour TOUT recuperer (echappatoire selects). |
|
||||||
|
|
||||||
|
### Override par ressource (rare)
|
||||||
|
|
||||||
|
Si une ressource a besoin d'un autre defaut (ex: payload lourd), utiliser les attributs sur l'operation. **JAMAIS `paginationEnabled: false`** sans whitelist explicite dans `tests/Architecture/CollectionsArePaginatedTest::EXCLUDED`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
new GetCollection(
|
||||||
|
paginationItemsPerPage: 5, // override taille par defaut
|
||||||
|
paginationMaximumItemsPerPage: 20, // override borne max
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selects et autocompletions
|
||||||
|
|
||||||
|
Pour alimenter un `<select>` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
useApi().get('/api/roles?pagination=false')
|
||||||
|
```
|
||||||
|
|
||||||
|
Le serveur retourne toute la collection, sans `view`. C'est l'echappatoire prevue par `pagination_client_enabled: true`. Sur les ressources a forte volumetrie, preferer une saisie assistee (recherche serveur via `?q=`) — a planifier dans un ticket dedie.
|
||||||
|
|
||||||
|
Les tests fonctionnels qui exercent ce comportement doivent egalement passer `?pagination=false` (cf. `CategoryListTest`, `PermissionApiTest`).
|
||||||
|
|
||||||
|
### Providers customs et pagination
|
||||||
|
|
||||||
|
Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface` **court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportes :
|
||||||
|
|
||||||
|
- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un `Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`.
|
||||||
|
- **DBAL** : implementer un paginator local conforme a `PaginatorInterface`. Exemple : `DbalPaginator` (Core) + `AuditLogProvider`.
|
||||||
|
|
||||||
|
Gerer l'echappatoire `?pagination=false` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||||
|
return $qb->getQuery()->getResult(); // tout retourner
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Garde-fou architecture
|
||||||
|
|
||||||
|
`tests/Architecture/CollectionsArePaginatedTest` scanne reflexivement toutes les classes `#[ApiResource]` sous `src/` et echoue si une `GetCollection` pose `paginationEnabled: false` hors whitelist `EXCLUDED`. Ajouter une entree a la whitelist requiert une justification courte + un ticket Lesstime ouvert.
|
||||||
|
|
||||||
## Repositories
|
## Repositories
|
||||||
|
|
||||||
- Interface : `*RepositoryInterface` dans `Domain/Repository/`
|
- Interface : `*RepositoryInterface` dans `Domain/Repository/`
|
||||||
@@ -40,6 +98,24 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
|
|||||||
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
||||||
- Spec complete : @doc/audit-log.md
|
- Spec complete : @doc/audit-log.md
|
||||||
|
|
||||||
|
### Libelle i18n du type d'entite (obligatoire avec `#[Auditable]`)
|
||||||
|
|
||||||
|
**Toute entite `#[Auditable]` doit avoir son libelle FR dans le bloc `audit.entity` de `frontend/i18n/locales/fr.json`.** C'est la contrepartie i18n de l'attribut : sans elle, le filtre « Type d'entite » de l'audit-log affiche le type technique brut (ex: `commercial.Client`) au lieu d'un libelle lisible.
|
||||||
|
|
||||||
|
Pourquoi : le filtre est dynamique (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents en base) ; des qu'un module audite une entite, son type y apparait. Le front (`formatEntityType`, `audit-log.vue`) construit la cle `audit.entity.<module>_<entity>` et, faute de traduction, **retombe silencieusement** sur le type brut.
|
||||||
|
|
||||||
|
Derivation de la cle (emplacement centralise + schema flat — decision ERP-99) :
|
||||||
|
|
||||||
|
| FQCN entite | `entity_type` (back) | Cle i18n (flat) |
|
||||||
|
|---|---|---|
|
||||||
|
| `App\Module\Commercial\Domain\Entity\Client` | `commercial.Client` | `commercial_client` |
|
||||||
|
| `App\Module\Commercial\Domain\Entity\ClientAddress` | `commercial.ClientAddress` | `commercial_clientaddress` |
|
||||||
|
| `App\Module\Catalog\Domain\Entity\Category` | `catalog.Category` | `catalog_category` |
|
||||||
|
|
||||||
|
Regle : `strtolower(module)` + `_` + `strtolower(Entity)`. Ajouter sa cle de libelle audit fait partie de la **definition de fini** d'une entite metier auditee.
|
||||||
|
|
||||||
|
**Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entites `#[Auditable]` et echoue si une seule n'a pas sa cle `audit.entity.*`. Conclusion : creer une entite `#[Auditable]` sans son libelle i18n casse `make test`.
|
||||||
|
|
||||||
## Timestampable + Blamable (obligatoire pour entites metier)
|
## Timestampable + Blamable (obligatoire pour entites metier)
|
||||||
|
|
||||||
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
|
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
|
||||||
@@ -74,3 +150,53 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
|
|||||||
## PostgreSQL
|
## PostgreSQL
|
||||||
|
|
||||||
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
|
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
|
||||||
|
|
||||||
|
## Migrations Doctrine
|
||||||
|
|
||||||
|
### Documentation SQL obligatoire (`COMMENT ON COLUMN`)
|
||||||
|
|
||||||
|
**Toute migration qui cree ou modifie une colonne d'une table metier doit poser un `COMMENT ON COLUMN` decrivant le champ.** La description est stockee dans `pg_description` et visible dans tous les outils d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir a lire les annotations PHP.
|
||||||
|
|
||||||
|
**Format de la description** :
|
||||||
|
- En francais
|
||||||
|
- ≤ 200 caracteres
|
||||||
|
- Semantique du champ — contraintes / lien RG si pertinent
|
||||||
|
- Pour les colonnes d'identifiant ou FK, mentionner la cible
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Migration : creation d'une colonne avec son commentaire dans la meme migration
|
||||||
|
$this->addSql("ALTER TABLE client ADD COLUMN siren VARCHAR(9) DEFAULT NULL");
|
||||||
|
$this->addSql("COMMENT ON COLUMN client.siren IS 'SIREN (9 chiffres) — identifiant legal entreprise. Unique parmi non-archives (RG-1.15).'");
|
||||||
|
|
||||||
|
// Cas FK : preciser la cible
|
||||||
|
$this->addSql("COMMENT ON COLUMN client.legal_form_id IS 'Reference forme juridique (SARL, SAS, SA...) — FK -> legal_form.id, ON DELETE RESTRICT.'");
|
||||||
|
|
||||||
|
// Cas booleen : preciser le sens et la valeur par defaut
|
||||||
|
$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'");
|
||||||
|
|
||||||
|
// Bonus : decrire la table elle-meme
|
||||||
|
$this->addSql("COMMENT ON TABLE client IS 'Repertoire clients (M1 Commercial) — entites archivables.'");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helper Timestampable/Blamable
|
||||||
|
|
||||||
|
Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` ajoutees par `TimestampableBlamableTrait` recoivent une description **standardisee** via le helper centralise pour eviter la duplication. Helper a creer ou appeler :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Dans la migration, apres avoir ajoute les 4 colonnes :
|
||||||
|
$this->addStandardTimestampableBlamableComments($schema, 'client');
|
||||||
|
```
|
||||||
|
|
||||||
|
L'implementation du helper applique :
|
||||||
|
- `created_at` : « Horodatage de creation de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||||
|
- `updated_at` : « Horodatage de derniere modification de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||||
|
- `created_by` : « ID de l'utilisateur ayant cree la ligne — null pour les creations hors HTTP (CLI, migration, fixture). FK -> user.id, ON DELETE SET NULL. »
|
||||||
|
- `updated_by` : « ID de l'utilisateur ayant modifie la ligne en dernier — null pour les modifications hors HTTP. FK -> user.id, ON DELETE SET NULL. »
|
||||||
|
|
||||||
|
### Garde-fou architecture
|
||||||
|
|
||||||
|
`tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` filtre sur le schema `public` et echoue si **une seule colonne** n'a pas de `col_description`. Seules les tables system (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de justification + ticket Lesstime ouvert pour le retrofit) sont tolerees.
|
||||||
|
|
||||||
|
Conclusion : si tu crees une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.
|
||||||
|
|||||||
@@ -53,6 +53,53 @@ Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer
|
|||||||
|
|
||||||
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut.
|
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut.
|
||||||
|
|
||||||
|
## Listes paginees (standard) — usePaginatedList obligatoire
|
||||||
|
|
||||||
|
**Toute liste qui consomme une `GetCollection` API doit passer par `usePaginatedList`** (`frontend/shared/composables/usePaginatedList.ts`). Le composable est le pendant front de la regle ABSOLUE n°13 (« toute collection est paginee cote back ») : il consomme l'envelope Hydra (`member` / `totalItems` / `view`) et expose un etat reactif a brancher directement sur `MalioDataTable`.
|
||||||
|
|
||||||
|
Pattern de reference :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadList,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = usePaginatedList<MyEntity>({ url: '/my-resources' })
|
||||||
|
|
||||||
|
onMounted(loadList)
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
:empty-message="t('foo.empty')"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Garanties offertes par le composable :
|
||||||
|
- Force `Accept: application/ld+json` → API Platform 4 renvoie bien `member` / `totalItems` (sans Accept, retour tableau plat sans pagination).
|
||||||
|
- Defaut 10 items/page, choix client 10 / 25 / 50, aligne sur le defaut serveur.
|
||||||
|
- Mutation `setFilters` / `setSort` / `setItemsPerPage` → retombe systematiquement en page 1.
|
||||||
|
- Cas limite « page hors borne apres filtre » : retombe automatiquement sur la derniere page valide (`tests/usePaginatedList.test.ts`).
|
||||||
|
- Etat 100 % local (refs internes a l'instance) — **jamais reflete dans l'URL**, conformement a la regle « Etat des tableaux — pas de persistance URL » ci-dessous.
|
||||||
|
|
||||||
|
A NE PAS faire :
|
||||||
|
- Charger une collection complete via `?itemsPerPage=999` pour bypasser la pagination. Le seul cas legitime de retour complet est l'alimentation d'un `<select>` sur un referentiel ≤ quelques dizaines d'entrees, et il passe par `?pagination=false` (echappatoire prevue par `pagination_client_enabled: true`).
|
||||||
|
- Reimplementer la pagination prev/next a la main au-dessus de `MalioDataTable` — le composant porte deja le selecteur items/page et les boutons Prev/Next.
|
||||||
|
- Persister `page`/`tri`/`filtre` dans la query string — meme regle que pour `<MalioDataTable>` brut (cf. section suivante).
|
||||||
|
|
||||||
## Etat des tableaux — pas de persistance URL
|
## Etat des tableaux — pas de persistance URL
|
||||||
|
|
||||||
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
|
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
|
||||||
|
|||||||
@@ -60,14 +60,9 @@ jobs:
|
|||||||
coverage: none
|
coverage: none
|
||||||
tools: composer:v2
|
tools: composer:v2
|
||||||
|
|
||||||
- name: Cache Composer
|
# Cache Composer retire : meme cause que cote front — le backend de cache
|
||||||
uses: actions/cache@v4
|
# du runner Gitea est injoignable (ETIMEDOUT) et fait timeouter le step
|
||||||
with:
|
# ~4 min 30. A re-activer si le serveur de cache du runner est repare.
|
||||||
path: ~/.composer/cache
|
|
||||||
key: composer-${{ hashFiles('composer.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
composer-
|
|
||||||
|
|
||||||
- name: Install PHP dependencies
|
- name: Install PHP dependencies
|
||||||
run: composer install --no-interaction --no-progress --prefer-dist
|
run: composer install --no-interaction --no-progress --prefer-dist
|
||||||
|
|
||||||
@@ -78,12 +73,23 @@ jobs:
|
|||||||
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
||||||
|
|
||||||
- name: Bootstrap test database
|
- name: Bootstrap test database
|
||||||
|
# Aligne sur la cible `test-db-setup` du makefile : apres
|
||||||
|
# `schema:update --force`, on RECREE manuellement l'index unique
|
||||||
|
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
|
||||||
|
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
|
||||||
|
# deleted_at IS NULL) et `schema:update` les considere comme
|
||||||
|
# orphelins et les DROP — collisions non detectees, tests d'unicite
|
||||||
|
# qui attendent 409 recoivent 201.
|
||||||
run: |
|
run: |
|
||||||
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||||
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
||||||
|
# Rejoue le catalogue COMMENT ON apres schema:update (cf. ERP-67) :
|
||||||
|
# schema:update drop les commentaires des tables managees par l'ORM.
|
||||||
|
php bin/console app:apply-column-comments --env=test --no-interaction
|
||||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||||
php bin/console app:sync-permissions --env=test --no-interaction
|
php bin/console app:sync-permissions --env=test --no-interaction
|
||||||
|
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||||
|
|
||||||
- name: Run PHPUnit
|
- name: Run PHPUnit
|
||||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||||
@@ -99,12 +105,15 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Pas de `cache: npm` : le backend de cache du runner Gitea est injoignable
|
||||||
|
# (ETIMEDOUT) et chaque tentative de restauration attend ~4 min 30 avant de
|
||||||
|
# timeout — c'est ce qui plombait le job. Node 22 est deja dans le
|
||||||
|
# tool-cache du runner (install instantane), et `npm ci` a froid ne prend
|
||||||
|
# que ~30s. A re-activer si le serveur de cache du runner est repare.
|
||||||
- name: Setup Node 22
|
- name: Setup Node 22
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -115,5 +124,8 @@ jobs:
|
|||||||
- name: Unit tests (Vitest)
|
- name: Unit tests (Vitest)
|
||||||
run: npm run test
|
run: npm run test
|
||||||
|
|
||||||
|
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
|
||||||
|
# (SPA), le prerender de generate n'apporte rien a une quality gate — on
|
||||||
|
# veut seulement valider que le bundle compile.
|
||||||
- name: Build production (nuxt build)
|
- name: Build production (nuxt build)
|
||||||
run: npm run build:dist
|
run: npm run build
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Contexte
|
## Contexte
|
||||||
CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable.
|
CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable.
|
||||||
|
|
||||||
Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
Doc humaine : `README.md` — Spec audit : `doc/audit-log.md` (à lire à la demande, non chargés en permanence).
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
- Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437)
|
- Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437)
|
||||||
@@ -24,6 +24,9 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
|||||||
9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation.
|
9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation.
|
||||||
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
||||||
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
||||||
|
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
|
||||||
|
13. **Toujours paginer toute collection exposee par l'API.** Aucun retour de collection complete (pas de provider qui retourne un array brut). Standard pose dans `config/packages/api_platform.yaml` : 10 items par defaut, max 50, le client peut basculer entre 10/25/50 et peut envoyer `?pagination=false` pour alimenter un select. Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` echoue si une `GetCollection` desactive la pagination sans whitelist. Details et exemples : @.claude/rules/backend.md § Pagination.
|
||||||
|
14. **`symfony.lock` est versionne** (au meme titre que `composer.lock`) — ne JAMAIS le `.gitignore`. C'est le registre des recipes Flex appliquees : sans lui, chaque `composer require` rejoue toutes les recipes et repollue `.env`, `config/bundles.php`, `docker-compose.yml` et recree du scaffolding parasite (`src/Entity/`, `src/Controller/`...). Le regenerer si besoin via `composer recipes:install --force`.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
@.claude/rules/architecture.md
|
@.claude/rules/architecture.md
|
||||||
@@ -34,7 +37,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
|||||||
@.claude/rules/git.md
|
@.claude/rules/git.md
|
||||||
@.claude/rules/workflow.md
|
@.claude/rules/workflow.md
|
||||||
|
|
||||||
## Commandes (liste complete dans @README.md)
|
## Commandes (liste complete dans `README.md`)
|
||||||
|
|
||||||
- Demarrer : `make start`
|
- Demarrer : `make start`
|
||||||
- Dev front (hot reload) : `make dev-nuxt` (port 3004)
|
- Dev front (hot reload) : `make dev-nuxt` (port 3004)
|
||||||
@@ -52,6 +55,7 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
|
|||||||
## A NE PAS faire
|
## A NE PAS faire
|
||||||
|
|
||||||
- Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`).
|
- Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`).
|
||||||
|
- Pas de provider API Platform qui retourne un array brut sur une `GetCollection` — court-circuite la pagination Hydra (`totalItems` / `view` absents). Utiliser `ApiPlatform\Doctrine\Orm\Paginator` (ORM) ou un paginator implementant `PaginatorInterface` (DBAL — cf. `DbalPaginator`).
|
||||||
- Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur).
|
- Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur).
|
||||||
- Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`.
|
- Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`.
|
||||||
- Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement.
|
- Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement.
|
||||||
@@ -66,3 +70,5 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
|
|||||||
## Credentials (dev)
|
## Credentials (dev)
|
||||||
|
|
||||||
`admin` / `admin` (ROLE_ADMIN) ; `alice` / `alice`, `bob` / `bob` (ROLE_USER).
|
`admin` / `admin` (ROLE_ADMIN) ; `alice` / `alice`, `bob` / `bob` (ROLE_USER).
|
||||||
|
|
||||||
|
Comptes demo des roles metier (seedes par `RbacDemoFixtures` / `app:seed-rbac --with-demo-users`, mot de passe `demo`) : `bureau` / `demo`, `compta` / `demo`, `commerciale` / `demo`, `usine` / `demo`. Matrice RBAC § 2.7 (M1 Clients) attachee aux roles correspondants.
|
||||||
|
|||||||
@@ -1,181 +1,362 @@
|
|||||||
# Starseed
|
# Starseed
|
||||||
|
|
||||||
CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
|
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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16
|
- **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16
|
||||||
- **Frontend** : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui
|
- **Frontend** : Nuxt 4 (SPA, SSR off), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, @nuxtjs/i18n
|
||||||
- **Auth** : JWT HTTP-only cookie (Lexik)
|
- **Auth** : JWT HTTP-only cookie (Lexik), login sur `/login_check`
|
||||||
- **Infra** : Docker Compose (dev + prod multi-stage)
|
- **Infra** : Docker Compose (dev + prod multi-stage)
|
||||||
- **CI/CD** : Gitea Actions (auto-tag + build Docker)
|
- **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 |
|
| Service | Port |
|
||||||
|------------|------|
|
|---------------|------|
|
||||||
| API (Nginx) | 8083 |
|
| API (Nginx) | 8083 |
|
||||||
| Frontend | 3004 |
|
| Frontend dev | 3004 |
|
||||||
| PostgreSQL | 5437 |
|
| PostgreSQL | 5437 |
|
||||||
|
|
||||||
## Commandes
|
---
|
||||||
|
|
||||||
| Commande | Description |
|
## Prérequis
|
||||||
|----------|-------------|
|
|
||||||
| `make start` | Demarrer les containers |
|
- Docker + Docker Compose
|
||||||
| `make stop` | Arreter les containers |
|
- `make`
|
||||||
| `make restart` | Redemarrer les containers |
|
- `nvm` (la version de Node est fixée par `.nvmrc`, voir `make node-use`)
|
||||||
| `make install` | Install complet |
|
|
||||||
| `make reset` | Tout supprimer et reinstaller |
|
Toutes les commandes `make` s'exécutent dans le container PHP (`php-starseed-fpm`) ;
|
||||||
| `make dev-nuxt` | Serveur dev Nuxt (hot reload) |
|
rien n'est requis sur l'hôte hormis Docker — **sauf les tests E2E**, qui tournent sur
|
||||||
| `make shell` | Shell dans le container PHP |
|
l'hôte (navigateur réel, voir [Tests](#tests)).
|
||||||
| `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 |
|
## Démarrage rapide
|
||||||
| `make test` | PHPUnit (tests back) |
|
|
||||||
| `make nuxt-test` | Vitest (tests unitaires front) |
|
```bash
|
||||||
| `make test-e2e` | Playwright (tests E2E front) |
|
make start # Démarre les containers Docker
|
||||||
| `make test-e2e-ui` | Playwright UI interactive (debug) |
|
make install # Composer + clés JWT + migrations + permissions + BDD de test
|
||||||
| `make seed-e2e` | Seed les 6 personas E2E |
|
make dev-nuxt # Serveur Nuxt avec hot reload (http://localhost:3004)
|
||||||
| `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 |
|
`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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`.
|
| Suite | Commande | Outil | Où |
|
||||||
- **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`.
|
| 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)
|
||||||
|
|
||||||
**Bootstrap E2E (une fois par poste)** :
|
|
||||||
```bash
|
```bash
|
||||||
make install-e2e-deps # Telecharge Chromium + libs systeme via apt (sudo)
|
make test # toute la suite
|
||||||
|
make test FILES=tests/Module/Commercial # un dossier / fichier ciblé
|
||||||
```
|
```
|
||||||
|
|
||||||
**Workflow E2E** :
|
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)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 : containers + dev server
|
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
|
||||||
make start && make seed-e2e && make dev-nuxt
|
make start && make seed-e2e && make dev-nuxt
|
||||||
|
|
||||||
# Terminal 2 : tests
|
# Terminal 2 — tests
|
||||||
make test-e2e
|
make test-e2e # headless
|
||||||
|
make test-e2e-ui # UI interactive (debug)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 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
|
## Architecture
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
- `config/modules.php` — liste des modules actifs
|
- `config/modules.php` — liste des modules actifs
|
||||||
- `config/sidebar.php` — structure de la sidebar (sections + items avec module owner)
|
- `config/sidebar.php` — structure de la sidebar (sections + items avec module owner)
|
||||||
- `GET /api/sidebar` — retourne les sections filtrees par les modules actifs + les routes desactivees
|
- `GET /api/modules` — IDs des modules actifs (public)
|
||||||
- Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API
|
- `GET /api/sidebar` — sections filtrées par modules actifs + routes désactivées (public)
|
||||||
|
|
||||||
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.
|
**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 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.
|
**Réorganiser la sidebar** : éditer `config/sidebar.php` uniquement — le code des
|
||||||
|
modules n'est pas touché.
|
||||||
|
|
||||||
## Structure
|
**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
|
||||||
|
|
||||||
```
|
```
|
||||||
src/ # Backend Symfony
|
src/ # Backend Symfony
|
||||||
Kernel.php
|
Shared/ # Noyau technique partagé (Domain/, Application/Bus/, Infrastructure/ApiPlatform/)
|
||||||
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/
|
Module/
|
||||||
Core/ # Module obligatoire (auth, users)
|
Core/ # Module obligatoire (auth, users, RBAC)
|
||||||
CoreModule.php # Declaration (ID, LABEL, REQUIRED)
|
CoreModule.php # Déclaration (ID, LABEL, REQUIRED, permissions())
|
||||||
Domain/
|
Domain/ Application/ Infrastructure/
|
||||||
Entity/ # User
|
Commercial/ Catalog/ Sites/ # Modules métier
|
||||||
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/
|
config/
|
||||||
modules.php # Source de verite activation
|
modules.php # Source de vérité : activation
|
||||||
sidebar.php # Source de verite navigation
|
sidebar.php # Source de vérité : navigation
|
||||||
version.yaml
|
packages/ # Config Symfony (doctrine, api_platform, security…)
|
||||||
packages/ # Config Symfony
|
migrations/ # Migrations d'initialisation (namespace racine : setup, RBAC, seed de base)
|
||||||
jwt/ # Cles JWT
|
|
||||||
migrations/ # Anciennes migrations
|
|
||||||
frontend/ # App Nuxt 4 (SPA)
|
frontend/ # App Nuxt 4 (SPA)
|
||||||
app/
|
app/ # Shell : layouts, middlewares (auth.global, modules.global)
|
||||||
layouts/ # default.vue, auth.vue
|
shared/ # Code inter-modules (composables, stores, utils, types)
|
||||||
middleware/ # auth.global.ts, modules.global.ts
|
modules/ # Layers Nuxt auto-détectés (core/, commercial/…)
|
||||||
shared/ # Code partage (hors modules)
|
i18n/locales/ # Traductions (sidebar.*, audit.entity.*, …)
|
||||||
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/
|
infra/
|
||||||
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
|
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug, .env.docker)
|
||||||
prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
|
prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
|
||||||
.gitea/workflows/ # CI Gitea (auto-tag, build Docker)
|
.gitea/workflows/ # CI Gitea (auto-tag, build Docker)
|
||||||
.claude/
|
|
||||||
skills/create-module/ # Skill Claude Code pour scaffolder un module
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
- **Auto Tag** : push sur `develop` → bump `config/version.yaml` → tag `vX.Y.Z`
|
- **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
|
- **Build Docker** : push tag `v*` → build image multi-stage → push Gitea Registry
|
||||||
|
|
||||||
Secrets requis dans Gitea :
|
Secrets requis dans Gitea :
|
||||||
|
|
||||||
- `RELEASE_TOKEN` — PAT avec droits `write:repository`
|
- `RELEASE_TOKEN` — PAT avec droits `write:repository`
|
||||||
- `REGISTRY_TOKEN` — token pour le registry Docker
|
- `REGISTRY_TOKEN` — token pour le registry Docker
|
||||||
|
|
||||||
## Credentials (dev)
|
---
|
||||||
|
|
||||||
| Username | Password | Role |
|
|
||||||
|----------|----------|------|
|
|
||||||
| admin | admin | ROLE_ADMIN |
|
|
||||||
| alice | alice | ROLE_USER |
|
|
||||||
| bob | bob | ROLE_USER |
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -185,4 +366,13 @@ Secrets requis dans Gitea :
|
|||||||
<type>(<scope optionnel>) : <message>
|
<type>(<scope optionnel>) : <message>
|
||||||
```
|
```
|
||||||
|
|
||||||
Types : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`
|
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/`.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
"nyholm/psr7": "^1.8",
|
"nyholm/psr7": "^1.8",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
||||||
|
"phpoffice/phpspreadsheet": "^5.7",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/console": "8.0.*",
|
"symfony/console": "8.0.*",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"symfony/expression-language": "8.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
|
"symfony/intl": "8.0.*",
|
||||||
"symfony/mime": "8.0.*",
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/monolog-bundle": "^4.0",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
|
|||||||
Generated
+514
-80
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d65a546151abb6b977fbf7f1c86d14fe",
|
"content-hash": "aada2e60fd7563f1498b5505b37e3f4b",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -1160,6 +1160,85 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-03-17T15:23:21+00:00"
|
"time": "2026-03-17T15:23:21+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "composer/pcre",
|
||||||
|
"version": "3.3.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/pcre.git",
|
||||||
|
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||||
|
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan": "<1.11.10"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.12 || ^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||||
|
"phpunit/phpunit": "^8 || ^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Pcre\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||||
|
"keywords": [
|
||||||
|
"PCRE",
|
||||||
|
"preg",
|
||||||
|
"regex",
|
||||||
|
"regular expression"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/composer/pcre/issues",
|
||||||
|
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-11-12T16:29:46+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "composer/semver",
|
"name": "composer/semver",
|
||||||
"version": "3.4.4",
|
"version": "3.4.4",
|
||||||
@@ -2630,6 +2709,191 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-20T17:47:00+00:00"
|
"time": "2025-12-20T17:47:00+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "maennchen/zipstream-php",
|
||||||
|
"version": "3.2.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"php-64bit": "^8.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.7",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
|
"mikey179/vfsstream": "^1.6",
|
||||||
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
|
"phpunit/phpunit": "^12.0",
|
||||||
|
"vimeo/psalm": "^6.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"guzzlehttp/psr7": "^2.4",
|
||||||
|
"psr/http-message": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ZipStream\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paul Duncan",
|
||||||
|
"email": "pabs@pablotron.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonatan Männchen",
|
||||||
|
"email": "jonatan@maennchen.ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jesse Donat",
|
||||||
|
"email": "donatj@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "András Kolesár",
|
||||||
|
"email": "kolesar@kolesar.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||||
|
"keywords": [
|
||||||
|
"stream",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||||
|
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/maennchen",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-11T18:38:28+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/complex",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Complex\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@lange.demon.co.uk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with complex numbers",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||||
|
"keywords": [
|
||||||
|
"complex",
|
||||||
|
"mathematics"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||||
|
},
|
||||||
|
"time": "2022-12-06T16:21:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/matrix",
|
||||||
|
"version": "3.0.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpdocumentor/phpdocumentor": "2.*",
|
||||||
|
"phploc/phploc": "^4.0",
|
||||||
|
"phpmd/phpmd": "2.*",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"sebastian/phpcpd": "^4.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Matrix\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@demon-angel.eu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with matrices",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||||
|
"keywords": [
|
||||||
|
"mathematics",
|
||||||
|
"matrix",
|
||||||
|
"vector"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||||
|
},
|
||||||
|
"time": "2022-12-02T22:17:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
@@ -3052,6 +3316,115 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-01-06T21:53:42+00:00"
|
"time": "2026-01-06T21:53:42+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpoffice/phpspreadsheet",
|
||||||
|
"version": "5.7.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
|
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
|
||||||
|
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/pcre": "^1||^2||^3",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
|
"markbaker/complex": "^3.0",
|
||||||
|
"markbaker/matrix": "^3.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
|
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||||
|
"ext-intl": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
|
"mitoteam/jpgraph": "^10.5",
|
||||||
|
"mpdf/mpdf": "^8.1.1",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||||
|
"phpunit/phpunit": "^10.5",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
|
"tecnickcom/tcpdf": "^6.5"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||||
|
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||||
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Maarten Balliauw",
|
||||||
|
"homepage": "https://blog.maartenballiauw.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"homepage": "https://markbakeruk.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Franck Lefevre",
|
||||||
|
"homepage": "https://rootslabs.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Erik Tilt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Adrien Crivelli"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Owen Leibman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||||
|
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||||
|
"keywords": [
|
||||||
|
"OpenXML",
|
||||||
|
"excel",
|
||||||
|
"gnumeric",
|
||||||
|
"ods",
|
||||||
|
"php",
|
||||||
|
"spreadsheet",
|
||||||
|
"xls",
|
||||||
|
"xlsx"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0"
|
||||||
|
},
|
||||||
|
"time": "2026-04-20T02:42:17+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpdoc-parser",
|
"name": "phpstan/phpdoc-parser",
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
@@ -3513,6 +3886,57 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"time": "2024-09-11T13:17:53+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "psr/simple-cache",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-fig/simple-cache.git",
|
||||||
|
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||||
|
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Psr\\SimpleCache\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "PHP-FIG",
|
||||||
|
"homepage": "https://www.php-fig.org/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common interfaces for simple caching",
|
||||||
|
"keywords": [
|
||||||
|
"cache",
|
||||||
|
"caching",
|
||||||
|
"psr",
|
||||||
|
"psr-16",
|
||||||
|
"simple-cache"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
|
||||||
|
},
|
||||||
|
"time": "2021-10-29T13:26:27+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/asset",
|
"name": "symfony/asset",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -5172,6 +5596,95 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-31T21:14:05+00:00"
|
"time": "2026-03-31T21:14:05+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/intl",
|
||||||
|
"version": "v8.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/intl.git",
|
||||||
|
"reference": "604a1dbbd67471e885e93274379cadd80dc33535"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/intl/zipball/604a1dbbd67471e885e93274379cadd80dc33535",
|
||||||
|
"reference": "604a1dbbd67471e885e93274379cadd80dc33535",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"symfony/string": "<7.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/filesystem": "^7.4|^8.0",
|
||||||
|
"symfony/var-exporter": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Intl\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/",
|
||||||
|
"/Resources/data/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Bernhard Schussek",
|
||||||
|
"email": "bschussek@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Eriksen Costa",
|
||||||
|
"email": "eriksen.costa@infranology.com.br"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Igor Wiedler",
|
||||||
|
"email": "igor@wiedler.ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides access to the localization data of the ICU library",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"i18n",
|
||||||
|
"icu",
|
||||||
|
"internationalization",
|
||||||
|
"intl",
|
||||||
|
"l10n",
|
||||||
|
"localization"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/intl/tree/v8.0.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/mime",
|
"name": "symfony/mime",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -8263,85 +8776,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2022-12-23T10:58:28+00:00"
|
"time": "2022-12-23T10:58:28+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "composer/pcre",
|
|
||||||
"version": "3.3.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/composer/pcre.git",
|
|
||||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
|
||||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^7.4 || ^8.0"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"phpstan/phpstan": "<1.11.10"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpstan/phpstan": "^1.12 || ^2",
|
|
||||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
|
||||||
"phpunit/phpunit": "^8 || ^9"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"phpstan": {
|
|
||||||
"includes": [
|
|
||||||
"extension.neon"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-main": "3.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Composer\\Pcre\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Jordi Boggiano",
|
|
||||||
"email": "j.boggiano@seld.be",
|
|
||||||
"homepage": "http://seld.be"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
|
||||||
"keywords": [
|
|
||||||
"PCRE",
|
|
||||||
"preg",
|
|
||||||
"regex",
|
|
||||||
"regular expression"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/composer/pcre/issues",
|
|
||||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://packagist.com",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/composer",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2024-11-12T16:29:46+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "composer/xdebug-handler",
|
"name": "composer/xdebug-handler",
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
use App\Module\Catalog\CatalogModule;
|
||||||
use App\Module\Commercial\CommercialModule;
|
use App\Module\Commercial\CommercialModule;
|
||||||
use App\Module\Core\CoreModule;
|
use App\Module\Core\CoreModule;
|
||||||
use App\Module\Sites\SitesModule;
|
use App\Module\Sites\SitesModule;
|
||||||
@@ -9,4 +10,5 @@ return [
|
|||||||
CoreModule::class,
|
CoreModule::class,
|
||||||
CommercialModule::class,
|
CommercialModule::class,
|
||||||
SitesModule::class,
|
SitesModule::class,
|
||||||
|
CatalogModule::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,3 +21,18 @@ api_platform:
|
|||||||
stateless: true
|
stateless: true
|
||||||
cache_headers:
|
cache_headers:
|
||||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||||
|
# === Pagination Hydra (regle projet : toute collection DOIT etre paginee) ===
|
||||||
|
# Standard datatable : 10 items par defaut, choix client 10 / 25 / 50.
|
||||||
|
# Borne dure cote serveur a 50 pour prevenir tout `?itemsPerPage=999999`
|
||||||
|
# (attaque memoire / deep-fetch). Le client peut neanmoins desactiver la
|
||||||
|
# pagination via `?pagination=false` pour alimenter un <select> ou autre
|
||||||
|
# vue "tout-en-un" — c'est l'echappatoire prevue pour les ressources
|
||||||
|
# servant a la fois de datatable et de source de select (Role,
|
||||||
|
# Permission, Site, CategoryType). Override par ressource possible via
|
||||||
|
# `paginationItemsPerPage` / `paginationMaximumItemsPerPage` /
|
||||||
|
# `paginationEnabled` sur l'attribut #[ApiResource] ou sur une operation.
|
||||||
|
pagination_enabled: true
|
||||||
|
pagination_items_per_page: 10
|
||||||
|
pagination_maximum_items_per_page: 50
|
||||||
|
pagination_client_items_per_page: true
|
||||||
|
pagination_client_enabled: true
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ doctrine:
|
|||||||
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
|
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
|
||||||
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
|
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
|
||||||
Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User
|
Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User
|
||||||
|
# Cible des ManyToMany Client.categories / ClientAddress.categories (M1).
|
||||||
|
# Permet au module Commercial de referencer une Category via le contrat
|
||||||
|
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||||
|
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||||
mappings:
|
mappings:
|
||||||
Core:
|
Core:
|
||||||
type: attribute
|
type: attribute
|
||||||
@@ -54,6 +58,28 @@ doctrine:
|
|||||||
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
||||||
prefix: 'App\Module\Sites\Domain\Entity'
|
prefix: 'App\Module\Sites\Domain\Entity'
|
||||||
alias: Sites
|
alias: Sites
|
||||||
|
# Mapping inconditionnel du module Catalog (meme logique que Sites) :
|
||||||
|
# la structure DB (category, category_type) existe meme si
|
||||||
|
# CatalogModule::class n'est pas encore wire dans config/modules.php
|
||||||
|
# (declaration du module = ticket 0.5 / ERP-47). L'ORM doit connaitre
|
||||||
|
# les entites pour que le schema soit en phase ; l'activation
|
||||||
|
# fonctionnelle passe exclusivement par config/modules.php.
|
||||||
|
Catalog:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity'
|
||||||
|
prefix: 'App\Module\Catalog\Domain\Entity'
|
||||||
|
alias: Catalog
|
||||||
|
# Mapping inconditionnel du module Commercial (meme logique que Catalog) :
|
||||||
|
# les tables (client, sous-collections, referentiels comptables) creees
|
||||||
|
# par la migration M1 (Version20260601000000) doivent etre connues de
|
||||||
|
# l'ORM. L'activation fonctionnelle passe par config/modules.php.
|
||||||
|
Commercial:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
|
||||||
|
prefix: 'App\Module\Commercial\Domain\Entity'
|
||||||
|
alias: Commercial
|
||||||
controller_resolver:
|
controller_resolver:
|
||||||
auto_mapping: false
|
auto_mapping: false
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ lexik_jwt_authentication:
|
|||||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||||
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
|
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
|
||||||
|
# Tolerance d'horloge (en secondes) appliquee a la validation des claims
|
||||||
|
# temporels iat / nbf / exp (LooseValidAt cote lcobucci). Sans cette marge
|
||||||
|
# (defaut 0), un recul d'horloge entre la signature (/login_check) et la
|
||||||
|
# requete suivante rend iat/nbf « dans le futur » -> « Invalid JWT Token »
|
||||||
|
# (401). Observe en dev sous WSL2/Docker (horloge CLOCK_REALTIME non
|
||||||
|
# monotone) : flakes intermittents de la suite PHPUnit (ERP-98). Benefice
|
||||||
|
# aussi en prod si les noeuds derivent legerement entre eux.
|
||||||
|
clock_skew: 15
|
||||||
remove_token_from_body_when_cookies_used: true
|
remove_token_from_body_when_cookies_used: true
|
||||||
token_extractors:
|
token_extractors:
|
||||||
authorization_header:
|
authorization_header:
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ return [
|
|||||||
'module' => 'sites',
|
'module' => 'sites',
|
||||||
'permission' => 'sites.view',
|
'permission' => 'sites.view',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.catalog.categories',
|
||||||
|
'to' => '/admin/categories',
|
||||||
|
'icon' => 'mdi:tag-multiple-outline',
|
||||||
|
'module' => 'catalog',
|
||||||
|
'permission' => 'catalog.categories.view',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.core.audit_log',
|
'label' => 'sidebar.core.audit_log',
|
||||||
'to' => '/admin/audit-log',
|
'to' => '/admin/audit-log',
|
||||||
@@ -96,6 +103,13 @@ return [
|
|||||||
'label' => 'sidebar.commercial.section',
|
'label' => 'sidebar.commercial.section',
|
||||||
'icon' => 'mdi:account-arrow-left-outline',
|
'icon' => 'mdi:account-arrow-left-outline',
|
||||||
'items' => [
|
'items' => [
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.commercial.clients',
|
||||||
|
'to' => '/clients',
|
||||||
|
'icon' => 'mdi:account-group-outline',
|
||||||
|
'module' => 'commercial',
|
||||||
|
'permission' => 'commercial.clients.view',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.commercial.suppliers',
|
'label' => 'sidebar.commercial.suppliers',
|
||||||
'to' => '/suppliers',
|
'to' => '/suppliers',
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.40'
|
app.version: '0.1.78'
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ Aucun pattern soft delete existant dans Starseed (vérifié, aucune entité ne p
|
|||||||
|
|
||||||
Index unique partiel sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL`. Permet de recréer une catégorie avec le même `(name, type)` après suppression logique. Postgres supporte nativement (`CREATE UNIQUE INDEX ... WHERE`). Pattern propre, pas besoin de validator applicatif maison côté unicité — la contrainte SQL fait le job.
|
Index unique partiel sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL`. Permet de recréer une catégorie avec le même `(name, type)` après suppression logique. Postgres supporte nativement (`CREATE UNIQUE INDEX ... WHERE`). Pattern propre, pas besoin de validator applicatif maison côté unicité — la contrainte SQL fait le job.
|
||||||
|
|
||||||
|
> **🔗 Évolution ERP-78 (refonte taxonomie M1)** : `Category` porte désormais une colonne **`code`** (`VARCHAR(50)`, NOT NULL), slug MAJUSCULE auto-généré du nom (figé à la création, lecture seule via l'API), avec un **second index unique partiel** `uq_category_code (code) WHERE deleted_at IS NULL`. Ce code est la clé métier stable utilisée par le M1 Commercial (RG-1.03 / RG-1.29). Détail : `docs/specs/M1-clients/spec-back.md` § 3.3.
|
||||||
|
|
||||||
### 2.5 Audit & traces temporelles — deux niveaux complémentaires
|
### 2.5 Audit & traces temporelles — deux niveaux complémentaires
|
||||||
|
|
||||||
Deux mécanismes **indépendants** cohabitent :
|
Deux mécanismes **indépendants** cohabitent :
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Cahier de test back — M1 Répertoire clients (ticket ERP-60 / #478)
|
||||||
|
|
||||||
|
Mapping **toutes les RG (§ 7) → test(s) PHPUnit**, à jour après ERP-60.
|
||||||
|
|
||||||
|
Légende source : `ERP-55` `ERP-56` `ERP-57` `ERP-58` = tests écrits par les wagons
|
||||||
|
précédents ; **`ERP-60`** = tests ajoutés par ce ticket (stratégie « combler les
|
||||||
|
trous, zéro duplication »).
|
||||||
|
|
||||||
|
## Stratégie
|
||||||
|
|
||||||
|
ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici
|
||||||
|
l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants
|
||||||
|
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04
|
||||||
|
fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
|
||||||
|
merge de la stack.
|
||||||
|
|
||||||
|
## Mapping RG → test
|
||||||
|
|
||||||
|
| RG | Intitulé | Test(s) | Source |
|
||||||
|
|----|----------|---------|--------|
|
||||||
|
| ~~RG-1.01~~ | _(supprimée V1 — refonte-contact)_ contact inline retiré du Client ; complétude couverte par RG-1.05 / RG-1.14 (`ClientContact`) | — | refonte-contact |
|
||||||
|
| ~~RG-1.02~~ | _(supprimée du Client V1)_ téléphones inline retirés du Client (testés sur `ClientContact`) | — | refonte-contact |
|
||||||
|
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
|
||||||
|
| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
|
||||||
|
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
|
||||||
|
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | ERP-60 / **ERP-76** |
|
||||||
|
| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 |
|
||||||
|
| RG-1.10 | ≥ 1 site sur adresse → 422 | `ClientSubResourceApiTest::testPostAddressWithoutSiteReturns422` | ERP-57 |
|
||||||
|
| RG-1.11 | billingEmail obligatoire ssi isBilling → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | ERP-60 / **ERP-76** |
|
||||||
|
| RG-1.12 | Virement → banque obligatoire → 422 | `ClientProcessorTest::testVirementWithoutBankIsUnprocessable` ; `::testVirementWithBankPasses` (unit) | ERP-55 |
|
||||||
|
| RG-1.13 | LCR → ≥ 1 RIB ; DELETE dernier RIB en LCR → 409 | `ClientProcessorTest::testLcrWithoutRibIsUnprocessable` / `::testLcrWithRibPasses` (unit) ; `ClientSubResourceApiTest::testDeleteLastRibUnderLcrReturns409` / `::testDeleteRibNonLcrReturns204` | ERP-55 / ERP-57 |
|
||||||
|
| RG-1.14 | ≥ 1 bloc Contact pour finaliser l'onglet | **Front-driven (pas de state machine back).** Back voisin : `ClientSubResourceApiTest::testDeleteLastContactReturns409` | ERP-57 |
|
||||||
|
| RG-1.15 | ~~Unicité SIREN~~ supprimée (Q4) — SIREN partageable | `ClientUniquenessTest::testDuplicateSirenIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
|
||||||
|
| RG-1.16 | companyName unique (case-insensitive) parmi actifs → 409 | `ClientApiTest::testPostDuplicateCompanyNameReturns409` ; `ClientMigrationTest::testCompanyNameActivePartialIndexExistsExactlyOnce` | ERP-55 / **ERP-60** |
|
||||||
|
| RG-1.17 | ~~Unicité email~~ supprimée (Q4) — email partageable | `ClientUniquenessTest::testDuplicateEmailIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
|
||||||
|
| RG-1.18 | companyName upper-cased serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testCompanyNameIsUppercased` (unit) | ERP-55 |
|
||||||
|
| RG-1.19 | firstName/lastName capitalize serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPersonNameIsTitleCased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` | ERP-55 / ERP-57 |
|
||||||
|
| RG-1.20 | Téléphones chiffres-seuls serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPhoneKeepsOnlyDigits` (unit) ; `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` (secondary) | ERP-55 / **ERP-60** |
|
||||||
|
| RG-1.21 | email lowercase serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testEmailIsLowercased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` / `::testPostAddressNormalizesBillingEmail` | ERP-55 / ERP-57 |
|
||||||
|
| RG-1.22 | Archive : permission `archive` + archivedAt + aucun autre champ | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` ; `::testPatchArchiveWithOtherFieldReturns422` ; `ClientProcessorTest` (unit, gating archive) | ERP-55 |
|
||||||
|
| RG-1.23 | Restauration : archivedAt=null ; **409 si conflit d'unicité** | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` (cas nominal) ; **`ClientArchiveTest::testRestoreConflictReturns409`** (409 restauration, gap P1) | ERP-55 / **ERP-60** |
|
||||||
|
| RG-1.24 | Liste exclut les archivés par défaut | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
|
||||||
|
| RG-1.25 | `?includeArchived=true` inclut les archivés | `ClientApiTest::testListIncludeArchivedReturnsArchived` | ERP-55 |
|
||||||
|
| RG-1.26 | Tri par défaut companyName ASC | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
|
||||||
|
| RG-1.27 | Timestampable/Blamable : created* figés, updated* mis à jour | `ClientAuditTest::testCreatedFrozenAndUpdatedByReflectsModifier` | **ERP-60** |
|
||||||
|
| RG-1.28 | PATCH multi-groupes sans permission → 403 strict (tout le payload) | `ClientProcessorTest::testStrictMixWithAccountingFieldIsForbidden` / `::testAccountingFieldWithoutPermissionIsForbidden` (unit) ; **`ClientPatchStrictTest::testMixedGroupsPatchWithoutAccountingPermissionIsForbidden`** (fonctionnel) | ERP-55 / **ERP-60** |
|
||||||
|
| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE** : `ClientAddress::validateCategoryTypes` (Assert\Callback) rejette une catégorie DISTRIBUTEUR/COURTIER en 422 (violation `categories`). Tests : `ClientAddressTest::testAddressRejectsDistributorCategory` / `::testAddressRejectsBrokerCategory` / `::testAddressAcceptsSectorCategory` / `::testAddressAcceptsOtherCategory` | **ERP-76** |
|
||||||
|
|
||||||
|
## Couvertures transverses
|
||||||
|
|
||||||
|
| Sujet | Test(s) | Source |
|
||||||
|
|-------|---------|--------|
|
||||||
|
| Audit iban/bic présents dans le diff (pas d'`#[AuditIgnore]`) | `ClientAuditTest::testRibCreateAuditIncludesIbanAndBic` | **ERP-60** |
|
||||||
|
| Sécurité générique : 401 anonyme + 403 sans `commercial.clients.view` | `ClientSecurityTest` (collection + détail) ; `ClientExportControllerTest::testForbiddenWithoutClientsViewPermission` / `::testUnauthorizedWhenAnonymous` | **ERP-60** / ERP-58 |
|
||||||
|
| Migration : index partiel unique présent (1 seul), pas de siren/email unique | `ClientMigrationTest` | **ERP-60** |
|
||||||
|
| Référentiels comptables read-only (405 écriture, 401/403) | `ReferentialApiTest` | ERP-56 |
|
||||||
|
| Export XLSX (colonnes accounting selon permission, 401/403) | `ClientExportControllerTest` | ERP-58 |
|
||||||
|
|
||||||
|
## Délégué à ERP-74 (#493) — NE PAS faire dans ERP-60
|
||||||
|
|
||||||
|
- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale /
|
||||||
|
Usine) : 200/403 par verbe et par onglet selon le rôle.
|
||||||
|
- **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec
|
||||||
|
champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec).
|
||||||
|
- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1.
|
||||||
|
|
||||||
|
## Gaps & suivi
|
||||||
|
|
||||||
|
- ~~**RG-1.29 (validation écriture)**~~ — **résolu en ERP-76**. La validation
|
||||||
|
d'écriture refuse désormais une catégorie de type `DISTRIBUTEUR`/`COURTIER` sur
|
||||||
|
une `ClientAddress` (→ 422, violation `categories`) via l'Assert\Callback
|
||||||
|
`ClientAddress::validateCategoryTypes`. Le filtrage de lecture reste
|
||||||
|
front-driven (SearchFilter). Couvert par `ClientAddressTest`.
|
||||||
|
- ~~**Violations CHECK → statut HTTP**~~ — **résolu en ERP-76**. Les règles
|
||||||
|
d'adresse RG-1.06/07/08/11 sont désormais rejetées en **422** par des
|
||||||
|
Assert\Callback applicatifs (`validateProspectExclusivity` /
|
||||||
|
`validateBillingEmailPresence`) qui s'exécutent AVANT la base. Les CHECK
|
||||||
|
Postgres (`chk_client_address_prospect_exclusive` /
|
||||||
|
`chk_client_address_billing_email`) restent en filet de sécurité. Les tests
|
||||||
|
`ClientAddressTest` assertent maintenant le 422 explicite (et non plus ≥ 400).
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# M1 · Ticket 1/3 (Backend) — Supprimer le contact inline du `Client`
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Retirer de l'entité `Client` (et de la table `client`) les **5 champs du contact
|
||||||
|
principal inline** : `firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`.
|
||||||
|
La gestion des contacts passe désormais **exclusivement** par la sous-entité
|
||||||
|
`ClientContact` (onglet « Contacts »), déjà en place et déjà porteuse des mêmes champs.
|
||||||
|
|
||||||
|
Le code M1 est **déjà livré en prod** : ce ticket inclut donc une **migration de données**
|
||||||
|
(backfill) pour ne perdre aucune information de contact existante avant de supprimer les
|
||||||
|
colonnes.
|
||||||
|
|
||||||
|
Contexte et justification : voir `README.md` du dossier `refonte-contact`.
|
||||||
|
|
||||||
|
## 2. Périmètre
|
||||||
|
|
||||||
|
### IN
|
||||||
|
|
||||||
|
- Migration Doctrine : **backfill puis suppression** des 5 colonnes de `client`.
|
||||||
|
- `Client` (entité) : supprimer les 5 propriétés, getters/setters, annotations ORM /
|
||||||
|
`Assert` / `Groups`.
|
||||||
|
- `ClientProcessor` : retirer les 5 champs de `MAIN_FIELDS`, `changedBusinessFields()`,
|
||||||
|
`normalize()` ; supprimer `validateMainContact()` (RG-1.01 — n'a plus d'objet).
|
||||||
|
- `DoctrineClientRepository::applySearch()` : trancher D1 (recherche) et l'appliquer.
|
||||||
|
- `ClientExportController` : trancher D2 (colonnes export) et l'appliquer.
|
||||||
|
- `ClientFixtures` : retirer les 5 paramètres inline de `ensureClient()` ; garantir que
|
||||||
|
chaque client seedé possède au moins 1 `ClientContact` (déjà géré par `addContact()`).
|
||||||
|
- Tests PHPUnit : mettre à jour / retirer les cas qui exercent ces 5 champs sur `Client`.
|
||||||
|
|
||||||
|
### OUT
|
||||||
|
|
||||||
|
- Toute modification de `ClientContact` / `ClientContactProcessor` : **inchangés** (c'est la
|
||||||
|
cible, les champs y restent). `ClientFieldNormalizer` reste tel quel (toujours appelé par
|
||||||
|
`ClientContactProcessor`).
|
||||||
|
- Le front (formulaires, vues, types, i18n) → **ticket 2/3**.
|
||||||
|
- Les specs (`spec-back.md`, `spec-front.md`, cahier de test) → **ticket 3/3**.
|
||||||
|
|
||||||
|
## 3. Fichiers à modifier
|
||||||
|
|
||||||
|
| Fichier | Action |
|
||||||
|
|---|---|
|
||||||
|
| `src/Module/Commercial/Domain/Entity/Client.php` | Supprimer props `firstName` (~l.158), `lastName` (~l.163), `phonePrimary` (~l.168), `phoneSecondary` (~l.172), `email` (~l.178) + leurs getters/setters (~l.329-382) + groupes `client:read`/`client:write:main` + `Assert\*`. |
|
||||||
|
| `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php` | Retirer les 5 clés de `MAIN_FIELDS` (~l.63) ; de `changedBusinessFields()` (~l.277-281) ; les 6 lignes de `normalize()` qui touchent email/phone/first/last/secondary (~l.433-441) ; supprimer `validateMainContact()` (~l.447-456) et son appel. |
|
||||||
|
| `src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php` | `applySearch()` (~l.110-124) : appliquer **D1**. |
|
||||||
|
| `src/Module/Commercial/Infrastructure/Controller/ClientExportController.php` | `buildHeaders()` (~l.94-114) + `buildRows()` (~l.121-143) : appliquer **D2**. |
|
||||||
|
| `src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php` | `ensureClient()` (~l.357-395) : retirer firstName/lastName/phonePrimary/phoneSecondary/email ; conserver `addContact()`. |
|
||||||
|
| `migrations/Version<timestamp>.php` (NOUVELLE) | Backfill + `DROP COLUMN` (cf. § 4). |
|
||||||
|
| `tests/Module/Commercial/**` | Voir § 5. |
|
||||||
|
|
||||||
|
## 4. Migration Doctrine — backfill puis suppression
|
||||||
|
|
||||||
|
> Migration **modulaire** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : ce
|
||||||
|
> n'est PAS une migration d'initialisation, le schéma `client` / `client_contact` existe
|
||||||
|
> déjà (règle ABSOLUE n°11).
|
||||||
|
|
||||||
|
### `up()`
|
||||||
|
|
||||||
|
1. **Backfill — ne créer un contact que pour les clients qui n'en ont aucun**, afin de ne
|
||||||
|
pas dupliquer le contact déjà recopié à la création (`prefillFirstContact`) :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO client_contact
|
||||||
|
(client_id, first_name, last_name, phone_primary, phone_secondary, email, position, created_at, updated_at)
|
||||||
|
SELECT c.id, c.first_name, c.last_name, c.phone_primary, c.phone_secondary, c.email, 0, NOW(), NOW()
|
||||||
|
FROM client c
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id)
|
||||||
|
AND (c.first_name IS NOT NULL OR c.last_name IS NOT NULL);
|
||||||
|
```
|
||||||
|
|
||||||
|
> Le `WHERE ... first_name OU last_name IS NOT NULL` respecte le CHECK
|
||||||
|
> `chk_client_contact_name`. Les rares clients sans nom de contact ET sans contact
|
||||||
|
> existant ne reçoivent pas de ligne (cas théorique : `phone_primary`/`email` étaient
|
||||||
|
> `NOT NULL` mais les noms nullables).
|
||||||
|
|
||||||
|
2. **Supprimer les 5 colonnes** :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE client
|
||||||
|
DROP COLUMN first_name,
|
||||||
|
DROP COLUMN last_name,
|
||||||
|
DROP COLUMN phone_primary,
|
||||||
|
DROP COLUMN phone_secondary,
|
||||||
|
DROP COLUMN email;
|
||||||
|
```
|
||||||
|
|
||||||
|
> Pas de `COMMENT ON COLUMN` à poser (on supprime). Vérifier qu'aucun index ne portait
|
||||||
|
> sur `email` (l'index unique `uq_client_email_active` a déjà été supprimé — décision Q4 /
|
||||||
|
> RG-1.17, cf. `ClientMigrationTest`).
|
||||||
|
|
||||||
|
### `down()` (best-effort)
|
||||||
|
|
||||||
|
1. Recréer les 5 colonnes (`phone_primary`/`email` en `NOT NULL` impose un défaut transitoire
|
||||||
|
ou un re-remplissage depuis le contact `position = 0`).
|
||||||
|
2. Re-remplir depuis `client_contact` (`position = 0`) si possible.
|
||||||
|
3. Reposer les `COMMENT ON COLUMN` d'origine (textes RG-1.19/1.20/1.21/1.01/1.17 — cf.
|
||||||
|
`migrations/Version20260601000000.php` l.251-255).
|
||||||
|
|
||||||
|
> `down()` ne peut pas restaurer parfaitement les données (ambiguïté si plusieurs contacts).
|
||||||
|
> Documenter cette limite dans le docblock de la migration.
|
||||||
|
|
||||||
|
## 5. Tests à mettre à jour
|
||||||
|
|
||||||
|
| Fichier | Action |
|
||||||
|
|---|---|
|
||||||
|
| `tests/Module/Commercial/Api/ClientApiTest.php` | Retirer firstName/lastName/phone/email des payloads POST/PATCH `client` et des assertions JSON. |
|
||||||
|
| `tests/.../ClientFormulaireMainTest.php` | Supprimer les tests RG-1.01 (firstName/lastName) et RG-1.02 (téléphones) **côté Client** — ils basculent côté `ClientContact` (couverts ailleurs). |
|
||||||
|
| `tests/.../ClientExportControllerTest.php` | Aligner les en-têtes/lignes attendus sur **D2**. |
|
||||||
|
| `tests/.../ClientMigrationTest.php` | Asserter que les 5 colonnes **n'existent plus** sur `client` ; vérifier le backfill (un client sans contact obtient bien 1 `client_contact`). |
|
||||||
|
| `tests/.../ClientFieldNormalizerTest.php` | Conserver les tests du normalizer (toujours utilisé par `ClientContact`) ; retirer les cas spécifiques aux champs `Client` s'il y en a. |
|
||||||
|
| RG-1.01/1.02 (matrice) | Ne plus tester sur `Client` ; vérifier qu'ils restent couverts sur `ClientContact` (RG-1.05). |
|
||||||
|
|
||||||
|
## 6. Décisions à trancher (cf. README § 3)
|
||||||
|
|
||||||
|
- **D1 — recherche** : recommandé = `LEFT JOIN client_contact` (fuzzy sur
|
||||||
|
`companyName` + contact `first_name`/`last_name`/`email`). Attention au `DISTINCT` /
|
||||||
|
risque de doublons de lignes si plusieurs contacts matchent (grouper par `client.id`).
|
||||||
|
- **D2 — export** : recommandé = alimenter les colonnes contact depuis le contact de plus
|
||||||
|
petit `position` (fetch-join `contacts` pour éviter le N+1).
|
||||||
|
|
||||||
|
## 7. Critères d'acceptation (DoD)
|
||||||
|
|
||||||
|
- [ ] Les colonnes `first_name`, `last_name`, `phone_primary`, `phone_secondary`, `email`
|
||||||
|
n'existent plus sur la table `client`.
|
||||||
|
- [ ] La migration est jouable sur une base seedée sans perte de contact (backfill vérifié)
|
||||||
|
et `down()` documenté comme best-effort.
|
||||||
|
- [ ] `Client`, `ClientProcessor`, `DoctrineClientRepository`, `ClientExportController`,
|
||||||
|
`ClientFixtures` ne référencent plus les 5 champs.
|
||||||
|
- [ ] D1 et D2 implémentées conformément à la décision validée.
|
||||||
|
- [ ] `ClientContact` / `ClientContactProcessor` / `ClientFieldNormalizer` inchangés.
|
||||||
|
- [ ] `make test` vert (notamment `tests/Architecture/ColumnsHaveSqlCommentTest` et
|
||||||
|
`EntitiesAreTimestampableBlamableTest`).
|
||||||
|
- [ ] `make php-cs-fixer-allow-risky` ne signale rien sur les fichiers touchés.
|
||||||
|
- [ ] Aucune régression du contrat de sérialisation : capturer le JSON réel de
|
||||||
|
`GET /api/clients/{id}` et vérifier l'absence des 5 champs (réflexe RETEX M1).
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Prompt d'implémentation — M1 · Ticket 1/3 (Backend)
|
||||||
|
|
||||||
|
Tu travailles sur le projet **Starseed** (Symfony 8 / API Platform 4 / Doctrine / PostgreSQL).
|
||||||
|
Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Commentaires en français,
|
||||||
|
code en anglais, `declare(strict_types=1);` partout.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Supprimer le **contact principal inline** de l'entité `Client` : les 5 champs
|
||||||
|
`firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`. Les contacts sont gérés
|
||||||
|
uniquement via la sous-entité `ClientContact` (onglet Contacts), déjà en place. Le code est
|
||||||
|
déjà en prod → migration avec **backfill** avant `DROP`.
|
||||||
|
|
||||||
|
La spec détaillée du ticket est dans `docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.md`.
|
||||||
|
Lis-la en entier, ainsi que le `README.md` du même dossier (décision + RG impactées + D1/D2).
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
|
||||||
|
1. **Explorer** : `Client.php`, `ClientProcessor.php`, `DoctrineClientRepository.php`,
|
||||||
|
`ClientExportController.php`, `ClientFixtures.php`, et `ClientContact.php` (pour confirmer
|
||||||
|
que la cible porte bien les mêmes champs).
|
||||||
|
2. **Demander la validation des décisions D1 (recherche) et D2 (export)** avant de coder —
|
||||||
|
défauts recommandés : D1 = LEFT JOIN sur `client_contact`, D2 = colonnes export depuis le
|
||||||
|
contact `position` minimal. Ne pas inventer un autre comportement.
|
||||||
|
3. **Migration** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : backfill
|
||||||
|
`INSERT INTO client_contact ... WHERE NOT EXISTS(...)` puis `ALTER TABLE client DROP COLUMN ...`
|
||||||
|
(les 5). `down()` best-effort documenté. Voir le SQL exact dans la spec § 4.
|
||||||
|
4. **Entité** : retirer les 5 props + getters/setters + `#[ORM\Column]` + `#[Assert\*]` +
|
||||||
|
`#[Groups(['client:read','client:write:main'])]`.
|
||||||
|
5. **Processor** : retirer de `MAIN_FIELDS`, `changedBusinessFields()`, `normalize()` ;
|
||||||
|
supprimer `validateMainContact()` et son appel.
|
||||||
|
6. **Repository** : `applySearch()` selon D1.
|
||||||
|
7. **Export** : `buildHeaders()` / `buildRows()` selon D2.
|
||||||
|
8. **Fixtures** : alléger `ensureClient()` ; garder `addContact()`.
|
||||||
|
9. **Tests** : mettre à jour `ClientApiTest`, `ClientFormulaireMainTest`,
|
||||||
|
`ClientExportControllerTest`, `ClientMigrationTest`, `ClientFieldNormalizerTest`
|
||||||
|
(cf. spec § 5). Ajouter une assertion que le backfill crée bien un contact pour un client
|
||||||
|
qui n'en avait pas.
|
||||||
|
|
||||||
|
## Garde-fous
|
||||||
|
|
||||||
|
- Ne touche **pas** `ClientContact`, `ClientContactProcessor`, `ClientFieldNormalizer`.
|
||||||
|
- Respecte les règles ABSOLUES : pagination, `#[Auditable]`, COMMENT ON COLUMN (ici on
|
||||||
|
supprime → pas de commentaire à poser, mais ne pas casser le garde-fou).
|
||||||
|
- Les RG-1.01 et RG-1.02 disparaissent **du Client** : leur équivalent (RG-1.05 / RG-1.14)
|
||||||
|
vit déjà sur `ClientContact`, ne le duplique pas.
|
||||||
|
|
||||||
|
## Vérification finale (obligatoire avant de dire « fini »)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make db-reset && make migration-migrate # migration rejouable sur base fraîche
|
||||||
|
make test # PHPUnit vert
|
||||||
|
make php-cs-fixer-allow-risky # lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis capture le JSON réel de `GET /api/clients/{id}` (avec un JWT) et confirme que les 5
|
||||||
|
champs ont disparu de la réponse et que `contacts[]` porte bien l'info (réflexe RETEX M1 :
|
||||||
|
on valide sur le contrat réel, pas sur les annotations).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# M1 · Ticket 2/3 (Frontend) — Retirer le bloc contact principal des écrans Client
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des
|
||||||
|
trois écrans Client — **création**, **consultation**, **modification** — ainsi que des
|
||||||
|
types, mappeurs, validations et clés i18n associés. La saisie des contacts se fait
|
||||||
|
désormais uniquement dans l'**onglet « Contacts »** (composant `ClientContactBlock`, déjà
|
||||||
|
en place et inchangé).
|
||||||
|
|
||||||
|
Dépend du **ticket 1/3 (back)** : l'API ne renvoie/n'accepte plus ces 5 champs sur `client`.
|
||||||
|
Contexte : voir `README.md` du dossier `refonte-contact`.
|
||||||
|
|
||||||
|
## 2. Périmètre
|
||||||
|
|
||||||
|
### IN — fichiers `frontend/modules/commercial/`
|
||||||
|
|
||||||
|
| Fichier | Action |
|
||||||
|
|---|---|
|
||||||
|
| `pages/clients/new.vue` | Supprimer le bloc principal Nom/Prénom/Téléphones/Email (~l.27-63), l'état `main.firstName/lastName/email`, `mainPhones` (~l.445-459), la fonction `prefillFirstContact()` (~l.658-665) et son appel, le mapping payload POST `phonePrimary/phoneSecondary` (~l.513-524). Adapter `isMainValid` (~l.479-493) : la validation principale ne porte plus que sur `companyName` (+ relation/catégories selon RG existantes). L'onglet **Contacts** devient le point de saisie des coordonnées ; garantir au moins un `ClientContactBlock` vide au départ. |
|
||||||
|
| `pages/clients/[id]/edit.vue` | Supprimer les 5 champs du bloc principal (~l.32-73). `mapMainDraft()` et `buildMainPayload()` ne portent plus ces champs. L'onglet Contacts reste éditable. |
|
||||||
|
| `pages/clients/[id]/index.vue` | Supprimer l'affichage lecture seule des 5 champs du bloc principal (~l.49-104, partie contact). Conserver l'onglet Contacts (lecture seule). |
|
||||||
|
| `types/clientForm.ts` | `MainFormDraft` : retirer `firstName`, `lastName`, `email`, `phonePrimary`, `phoneSecondary`, `hasSecondaryPhone`. Garder `ContactFormDraft` (inchangé). |
|
||||||
|
| `types/clientConsultation.ts` | `ClientDetail` : retirer `firstName/lastName/phonePrimary/phoneSecondary/email` (les commentaires « Contact principal »). Garder `ContactRead`. |
|
||||||
|
| `utils/clientEdit.ts` | `mapMainDraft()` et `buildMainPayload()` : retirer les 5 champs. Garder `buildContactPayload()`. |
|
||||||
|
| `utils/clientConsultation.ts` | Retirer toute lecture des 5 champs inline du client (garder `mapContactToDraft`, `contactOptionsOf`). |
|
||||||
|
| `i18n/locales/fr.json` | Retirer `commercial.clients.form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone`. **Conserver** tout le bloc `commercial.clients.form.contact.*`. Vérifier qu'aucune autre vue ne référence les clés retirées. |
|
||||||
|
| `**/__tests__/*.spec.ts` | Mettre à jour `clientFormRules.spec.ts`, `clientEdit.spec.ts`, `clientConsultation.spec.ts` (cf. § 4). |
|
||||||
|
|
||||||
|
### OUT
|
||||||
|
|
||||||
|
- `ClientContactBlock.vue`, l'onglet Contacts, `useClient`, la liste/répertoire
|
||||||
|
(`pages/clients/index.vue` — ses colonnes n'affichent déjà pas le contact inline) :
|
||||||
|
**inchangés**.
|
||||||
|
- Le back → ticket 1/3. Les specs → ticket 3/3.
|
||||||
|
|
||||||
|
## 3. Comportement attendu après modification
|
||||||
|
|
||||||
|
- **Création** : le formulaire principal demande l'entreprise (et relation/catégories selon
|
||||||
|
l'existant), plus de Nom/Prénom/Téléphone/Email inline. L'utilisateur renseigne les
|
||||||
|
coordonnées dans l'onglet **Contacts**. La création reste valide tant qu'il y a
|
||||||
|
`companyName` **et** ≥ 1 bloc Contact valide (Nom OU Prénom) — RG-1.05/RG-1.14 inchangées.
|
||||||
|
- **Consultation** : plus de bloc contact principal ; l'onglet Contacts affiche les
|
||||||
|
contacts.
|
||||||
|
- **Modification** : idem ; le PATCH du groupe `client:write:main` n'envoie plus les 5
|
||||||
|
champs.
|
||||||
|
|
||||||
|
## 4. Tests Vitest à mettre à jour
|
||||||
|
|
||||||
|
- `clientFormRules.spec.ts` : la validité du « principal » ne dépend plus de
|
||||||
|
firstName/email/phone ; conserver `isContactNamed()` (RG-1.05) sur les blocs Contacts.
|
||||||
|
- `clientEdit.spec.ts` : `buildMainPayload()` ne contient plus les 5 champs ; `mapMainDraft()`
|
||||||
|
non plus.
|
||||||
|
- `clientConsultation.spec.ts` : retirer les assertions sur les 5 champs inline.
|
||||||
|
|
||||||
|
## 5. Tips & rappels projet
|
||||||
|
|
||||||
|
- `useApi()` obligatoire (jamais `$fetch`/`ofetch`). Composants `Malio*` obligatoires.
|
||||||
|
- État de tableau jamais dans l'URL (règle inchangée).
|
||||||
|
- Les valeurs sont **normalisées côté serveur** (Capitalize / chiffres / lowercase) : le
|
||||||
|
front envoie la saisie et réaffiche la valeur renvoyée — ne pas réintroduire de
|
||||||
|
normalisation front.
|
||||||
|
- Ne pas créer de clé i18n orpheline ni laisser de clé `form.main.*` morte.
|
||||||
|
|
||||||
|
## 6. Critères d'acceptation (DoD)
|
||||||
|
|
||||||
|
- [ ] Les 3 écrans n'affichent plus Nom/Prénom/Téléphone/Téléphone 2/Email en bloc principal.
|
||||||
|
- [ ] Le parcours de création fonctionne avec `companyName` + onglet Contacts (≥ 1 contact).
|
||||||
|
- [ ] `MainFormDraft` / `ClientDetail` ne déclarent plus les 5 champs ; `mapMainDraft` /
|
||||||
|
`buildMainPayload` non plus.
|
||||||
|
- [ ] Aucune clé i18n `form.main.firstName/lastName/email/phone*` restante ni référencée.
|
||||||
|
- [ ] `make nuxt-test` vert.
|
||||||
|
- [ ] Vérification visuelle du golden path (`make dev-nuxt`, port 3004) : création →
|
||||||
|
consultation → modification d'un client sans bloc contact inline.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Prompt d'implémentation — M1 · Ticket 2/3 (Frontend)
|
||||||
|
|
||||||
|
Projet **Starseed** (Nuxt 4 / Vue 3 Composition API / TypeScript / @malio/layer-ui).
|
||||||
|
Lis `CLAUDE.md` et `.claude/rules/frontend.md` avant de coder. Commentaires en français,
|
||||||
|
code en anglais, 4 espaces d'indentation.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des
|
||||||
|
écrans Client (création / consultation / modification) et de tout le code associé (types,
|
||||||
|
mappeurs, validations, i18n). Les contacts restent gérés par l'onglet **Contacts**
|
||||||
|
(`ClientContactBlock`, inchangé).
|
||||||
|
|
||||||
|
Spec détaillée : `docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.md` (lis-la en
|
||||||
|
entier + le `README.md` du dossier). Ce ticket dépend du ticket back (l'API ne porte plus
|
||||||
|
les 5 champs sur `client`).
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
|
||||||
|
1. Explorer `frontend/modules/commercial/` : `pages/clients/new.vue`, `[id]/edit.vue`,
|
||||||
|
`[id]/index.vue`, `types/clientForm.ts`, `types/clientConsultation.ts`,
|
||||||
|
`utils/clientEdit.ts`, `utils/clientConsultation.ts`, `i18n/locales/fr.json`.
|
||||||
|
2. Supprimer le bloc principal des 3 écrans + l'état réactif `main.firstName/lastName/email`,
|
||||||
|
`mainPhones`, `prefillFirstContact()`.
|
||||||
|
3. Adapter `isMainValid` : ne dépend plus que de `companyName` (+ relation/catégories selon
|
||||||
|
l'existant). La garantie « ≥ 1 contact valide » reste portée par l'onglet Contacts.
|
||||||
|
4. Nettoyer les types (`MainFormDraft`, `ClientDetail`) et les mappeurs (`mapMainDraft`,
|
||||||
|
`buildMainPayload`, `clientConsultation`).
|
||||||
|
5. Retirer les clés i18n `form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone` ;
|
||||||
|
vérifier par recherche qu'aucune vue ne les utilise plus. **Garder** `form.contact.*`.
|
||||||
|
6. Mettre à jour les specs Vitest (`clientFormRules`, `clientEdit`, `clientConsultation`).
|
||||||
|
|
||||||
|
## Garde-fous
|
||||||
|
|
||||||
|
- `useApi()` uniquement ; composants `Malio*` uniquement ; pas d'état tableau dans l'URL.
|
||||||
|
- Ne touche pas `ClientContactBlock.vue`, l'onglet Contacts, ni la liste/répertoire.
|
||||||
|
- Pas de normalisation front (le serveur normalise).
|
||||||
|
|
||||||
|
## Vérification finale
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make nuxt-test # Vitest vert
|
||||||
|
make dev-nuxt # port 3004 — golden path manuel
|
||||||
|
```
|
||||||
|
|
||||||
|
Golden path à vérifier dans le navigateur : créer un client (entreprise + 1 contact dans
|
||||||
|
l'onglet Contacts), le consulter, le modifier — sans aucun bloc contact inline.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# M1 · Ticket 3/3 (Specs) — Acter la suppression du contact inline dans les specs M1
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Mettre à jour la **documentation fonctionnelle/technique M1** pour refléter la décision :
|
||||||
|
le contact principal inline est supprimé du `Client`, les contacts vivent uniquement dans
|
||||||
|
`ClientContact`. Les specs sont la **source de vérité** du projet (cf. `workflow.md`) : elles
|
||||||
|
doivent décrire le modèle cible, pas l'ancien.
|
||||||
|
|
||||||
|
> Idéalement réalisé **avant** les tickets 1 et 2 (la spec guide le code), mais peut être
|
||||||
|
> fait en parallèle. À minima, ne pas merger le code sans aligner la spec.
|
||||||
|
|
||||||
|
## 2. Fichiers à modifier
|
||||||
|
|
||||||
|
| Fichier | Sections concernées |
|
||||||
|
|---|---|
|
||||||
|
| `docs/specs/M1-clients/spec-back.md` | § 3.1 diagramme E-R (retirer les 5 colonnes du bloc `client`) ; § 3.2 migration SQL `CREATE TABLE client` (retirer `first_name`/`last_name`/`phone_primary`/`phone_secondary`/`email` + leurs COMMENT) ; § 3.4 squelette entité `Client` (retirer les 5 props) ; § 4.3 exemple payload `POST /api/clients` (retirer les 5 champs) ; § 4.1 filtre `?search=` (refléter D1) ; § 4.6 export (refléter D2) ; § 7 RG (voir § 3 ci-dessous) ; § 8 cahier de tests (déplacer RG-1.01/1.02 vers ClientContact). |
|
||||||
|
| `docs/specs/M1-clients/spec-front.md` | « Formulaire principal » (l.85-103) : retirer les lignes Nom/Prénom/Téléphone/Téléphone 2/Email ; écrans Consultation / Modification ; règles de formatage. Préciser que les coordonnées se saisissent dans l'onglet Contact. |
|
||||||
|
| `docs/specs/M1-clients/cahier-test-back-M1.md` | Retirer / requalifier les lignes RG-1.01 et RG-1.02 (désormais couvertes par RG-1.05 sur `ClientContact`). |
|
||||||
|
|
||||||
|
## 3. Traitement des règles de gestion
|
||||||
|
|
||||||
|
- **RG-1.01** (firstName OU lastName obligatoire sur Client) → marquer **supprimée** :
|
||||||
|
« Remplacée par RG-1.05 (≥ 1 contact valide) + RG-1.14 (≥ 1 bloc Contact). Le contact
|
||||||
|
principal inline n'existe plus. »
|
||||||
|
- **RG-1.02** (max 2 téléphones sur Client) → marquer **supprimée du Client** (reste
|
||||||
|
applicable aux blocs `ClientContact`).
|
||||||
|
- **RG-1.19 / RG-1.20 / RG-1.21** (normalisation) → préciser que le **scope `Client`
|
||||||
|
disparaît** ; la normalisation reste sur `ClientContact` (et `ClientAddress.billingEmail`
|
||||||
|
pour RG-1.21).
|
||||||
|
- Ne **pas renuméroter** les RG existantes (éviter le drift avec le code/tests) : marquer
|
||||||
|
« supprimée / requalifiée » en place, avec date et renvoi à la décision.
|
||||||
|
|
||||||
|
## 4. Forme
|
||||||
|
|
||||||
|
- Bumper la version des deux specs (`version: V0` → `V1`) dans le frontmatter, avec une
|
||||||
|
entrée d'historique : date `2026-06-03`, motif « Suppression du contact inline du Client
|
||||||
|
(refonte-contact) », auteur.
|
||||||
|
- Ajouter un encadré « Décision » en tête de la section modèle de données, renvoyant au
|
||||||
|
`README.md` du dossier `refonte-contact`.
|
||||||
|
- Conserver le style des specs (sections numérotées, tableaux RG, exemples JSON).
|
||||||
|
|
||||||
|
## 5. Critères d'acceptation (DoD)
|
||||||
|
|
||||||
|
- [ ] `spec-back.md` : aucune mention des 5 colonnes inline dans le modèle `client`
|
||||||
|
(E-R + SQL + entité + payload) ; RG-1.01/1.02 marquées supprimées ; D1/D2 décrites.
|
||||||
|
- [ ] `spec-front.md` : le formulaire principal ne liste plus les champs de contact ;
|
||||||
|
l'onglet Contact est présenté comme seul lieu de saisie des coordonnées.
|
||||||
|
- [ ] `cahier-test-back-M1.md` : RG-1.01/1.02 retirées/requalifiées.
|
||||||
|
- [ ] Versions bumpées (V1) + historique daté dans les deux specs.
|
||||||
|
- [ ] Cohérence vérifiée avec les tickets 1 et 2 (mêmes décisions D1/D2).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Prompt d'implémentation — M1 · Ticket 3/3 (Specs)
|
||||||
|
|
||||||
|
Projet **Starseed**. Tâche **documentaire** : mettre à jour les specs M1 Clients pour acter
|
||||||
|
la suppression du contact principal inline du `Client`. Les specs sont la source de vérité ;
|
||||||
|
elles doivent décrire le modèle cible.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Modifier `docs/specs/M1-clients/spec-back.md`, `spec-front.md` et `cahier-test-back-M1.md`
|
||||||
|
pour retirer le contact inline du `Client` (5 champs `firstName/lastName/phonePrimary/
|
||||||
|
phoneSecondary/email`) — les contacts vivent uniquement dans `ClientContact`.
|
||||||
|
|
||||||
|
Spec du ticket : `docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.md` (lis-la + le
|
||||||
|
`README.md` du dossier, qui contient la décision, les RG impactées et les décisions D1/D2).
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
|
||||||
|
1. Lire les 3 fichiers de specs M1 visés, repérer toutes les occurrences des 5 champs
|
||||||
|
(diagramme E-R, CREATE TABLE client, squelette entité, payload POST, filtre search,
|
||||||
|
export, RG, cahier de test).
|
||||||
|
2. Retirer les 5 colonnes du modèle `client` (E-R + SQL + entité + exemple JSON).
|
||||||
|
3. Marquer **supprimées** RG-1.01 et RG-1.02 (renvoi à RG-1.05/RG-1.14 sur `ClientContact`),
|
||||||
|
restreindre le scope de RG-1.19/1.20/1.21 à `ClientContact`. **Ne pas renuméroter** les RG.
|
||||||
|
4. Refléter les décisions D1 (recherche) et D2 (export) une fois tranchées.
|
||||||
|
5. Côté `spec-front.md` : retirer les champs de contact du formulaire principal ; présenter
|
||||||
|
l'onglet Contact comme seul lieu de saisie.
|
||||||
|
6. Bumper `version: V0 → V1` + ajouter une entrée d'historique datée (2026-06-03).
|
||||||
|
|
||||||
|
## Garde-fous
|
||||||
|
|
||||||
|
- Ne touche pas au code, uniquement aux `.md` de specs.
|
||||||
|
- Garde le style existant (sections numérotées, tableaux RG, exemples JSON).
|
||||||
|
- Cohérence stricte avec les tickets 1 (back) et 2 (front) : mêmes décisions D1/D2.
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
|
||||||
|
Relire les 3 fichiers : plus aucune mention des 5 champs inline dans le modèle `client` ;
|
||||||
|
RG-1.01/1.02 marquées supprimées ; versions à V1 avec historique.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Amendement des tickets M2 existants — suppression du contact inline du `Supplier`
|
||||||
|
|
||||||
|
Les 14 tickets M2 (n° 84–97, groupe Lesstime « M2 — Répertoire fournisseurs ») ont été
|
||||||
|
rédigés sur le modèle initial **avec** contact inline. La décision `refonte-contact` les
|
||||||
|
amende : `Supplier` ne porte **plus** les 5 champs `firstName/lastName/phonePrimary/
|
||||||
|
phoneSecondary/email` ; les contacts vivent uniquement dans `SupplierContact` (onglet
|
||||||
|
Contacts). Comme M2 n'est pas codé, il suffit de **ne jamais créer** ces colonnes/champs.
|
||||||
|
|
||||||
|
## Bandeau injecté en tête des tickets impactés
|
||||||
|
|
||||||
|
> ⚠️ **AMENDEMENT 2026-06-03 — refonte-contact.** Le contact principal inline est
|
||||||
|
> **supprimé** du `Supplier` : ne pas créer/saisir les colonnes ni les champs `firstName`,
|
||||||
|
> `lastName`, `phonePrimary`, `phoneSecondary`, `email` sur l'entité/le formulaire
|
||||||
|
> `Supplier`. Les contacts sont gérés **uniquement** via `SupplierContact` (onglet
|
||||||
|
> Contacts). RG-2.01 et RG-2.02 sont supprimées (équivalent assuré par RG-2.04 / RG-2.13).
|
||||||
|
> RG-2.12 ne s'applique qu'à `companyName` + `SupplierContact`. Décisions transverses
|
||||||
|
> recherche (D1) et export (D2) : cf. `docs/specs/M1-clients/refonte-contact/README.md`.
|
||||||
|
|
||||||
|
## Tickets à amender
|
||||||
|
|
||||||
|
### Back
|
||||||
|
|
||||||
|
| Ticket | n° | Impact |
|
||||||
|
|---|---|---|
|
||||||
|
| migration BDD M2 (supplier + sous-collections) | #85 | Retirer `first_name/last_name/phone_primary/phone_secondary/email` du `CREATE TABLE supplier` et leurs `COMMENT ON COLUMN`. `supplier_contact` inchangé. |
|
||||||
|
| entités + repositories M2 | #86 | `Supplier` : retirer les 5 props + `Assert\Callback` RG-2.01. `SupplierContact` inchangé. |
|
||||||
|
| SupplierProvider + SupplierProcessor | #87 | Retirer la validation RG-2.01, la normalisation des champs inline, leur présence dans `MAIN_FIELDS` / changedFields. Recherche selon D1. |
|
||||||
|
| export XLSX fournisseurs | #91 | Colonnes contact selon D2 (depuis le contact principal, ou supprimées). |
|
||||||
|
| tests PHPUnit M2 | #92 | RG-2.01/2.02 testées sur `SupplierContact` (pas `Supplier`) ; contrat de sérialisation sans les 5 champs inline sur le supplier. |
|
||||||
|
|
||||||
|
### Front
|
||||||
|
|
||||||
|
| Ticket | n° | Impact |
|
||||||
|
|---|---|---|
|
||||||
|
| page Ajouter un fournisseur (`/suppliers/new`) + `useSupplierForm` | #94 | Retirer le bloc contact principal du formulaire + le pré-remplissage du 1er contact. Saisie des coordonnées dans l'onglet Contacts. |
|
||||||
|
| page Consultation fournisseur (`/suppliers/{id}`) | #95 | Retirer l'affichage du bloc contact principal. |
|
||||||
|
| page Modification fournisseur (`/suppliers/{id}/edit`) | #96 | Retirer les 5 champs du bloc principal ; payload `supplier:write:main` sans ces champs. |
|
||||||
|
|
||||||
|
### Léger
|
||||||
|
|
||||||
|
| Ticket | n° | Impact |
|
||||||
|
|---|---|---|
|
||||||
|
| page Répertoire fournisseurs + datatable | #93 | Recherche « nom / contact / email » selon D1. Datatable : colonnes inchangées (pas de contact inline en colonne). |
|
||||||
|
| i18n + sidebar fournisseurs | #97 | Ne pas créer les clés i18n `form.main.firstName/lastName/email/phone*` (garder `form.contact.*`). |
|
||||||
|
|
||||||
|
## Tickets NON impactés
|
||||||
|
|
||||||
|
- #84 (taxonomie FOURNISSEUR), #88 (sous-ressources contacts/adresses/ribs —
|
||||||
|
`SupplierContact` est la cible, inchangé), #89 (validators Information Commerciale /
|
||||||
|
catégorie / RG-2.07-2.08), #90 (RBAC fournisseurs).
|
||||||
|
|
||||||
|
## Méthode d'amendement
|
||||||
|
|
||||||
|
Pour chaque ticket impacté : **préfixer** la description existante du bandeau ci-dessus
|
||||||
|
(sans rien supprimer du contenu d'origine), via `mcp__lesstime__update-task`
|
||||||
|
(`description` = bandeau + description actuelle). La méthode préserve l'historique et reste
|
||||||
|
réversible (retirer le bandeau).
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# M2 · Ticket Specs — Retirer le contact inline du `Supplier` dans les specs M2
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Mettre à jour les specs **M2 Fournisseurs** déjà rédigées pour **ne plus inclure** le contact
|
||||||
|
principal inline sur le `Supplier`. M2 est le jumeau strict de M1 (`Supplier` /
|
||||||
|
`SupplierContact` / `SupplierAddress` / `SupplierRib`) et n'est **pas encore codé** : il faut
|
||||||
|
donc corriger la conception **en amont**, pour que les 14 tickets M2 « prêts à dev » soient
|
||||||
|
implémentés directement sans les 5 colonnes inline.
|
||||||
|
|
||||||
|
> Pendant de M1 ticket 3/3, mais côté M2 : **aucune migration de suppression ni backfill** —
|
||||||
|
> on retire simplement le contact inline du modèle cible. Contexte : `README.md` du dossier
|
||||||
|
> `refonte-contact`.
|
||||||
|
|
||||||
|
## 2. Fichiers à modifier
|
||||||
|
|
||||||
|
| Fichier | Sections concernées |
|
||||||
|
|---|---|
|
||||||
|
| `docs/specs/M2-suppliers/spec-back.md` | § 3.1 diagramme E-R (l.175-179 : retirer les 5 colonnes du bloc `supplier`) ; § 3.2 `CREATE TABLE supplier` (l.227-231) ; § 3.4 squelette entité `Supplier` (l.496-517 : props + `Assert\Callback` RG-2.01) ; § 4 exemples payload POST/GET (l.782-805, 867-871) ; recherche `?search=` (l.847 : refléter D1) ; export (refléter D2) ; § contrat de sérialisation (l.725, 729) ; § 7 RG (voir § 3). |
|
||||||
|
| `docs/specs/M2-suppliers/spec-front.md` | « Formulaire principal » (l.105-117 : retirer Nom/Prénom/Téléphone/Téléphone 2/Email) ; onglet « Contact » (l.140-157 : retirer la phrase de pré-remplissage depuis le formulaire principal, l.142) ; écrans Consultation/Modification ; règles de formatage (l.283-285) ; recherche (l.76 : refléter D1). |
|
||||||
|
|
||||||
|
## 3. Traitement des règles de gestion M2
|
||||||
|
|
||||||
|
- **RG-2.01** (firstName OU lastName obligatoire sur Supplier) → **supprimée** : remplacée
|
||||||
|
par RG-2.04 (≥ 1 contact valide) + RG-2.13 (≥ 1 bloc Contact). Le contact inline n'existe
|
||||||
|
plus sur `Supplier`.
|
||||||
|
- **RG-2.02** (max 2 téléphones sur Supplier) → **supprimée du Supplier** (reste sur
|
||||||
|
`SupplierContact`).
|
||||||
|
- **RG-2.12** (normalisation Capitalize / chiffres / lowercase) → restreindre le scope :
|
||||||
|
s'applique à `companyName` (UPPERCASE) et aux champs de `SupplierContact` ; **plus** aux
|
||||||
|
champs inline du `Supplier` (qui disparaissent).
|
||||||
|
- Ne pas renuméroter les RG : marquer « supprimée / requalifiée » en place, avec date.
|
||||||
|
|
||||||
|
## 4. Forme
|
||||||
|
|
||||||
|
- Bumper la version des deux specs M2 + entrée d'historique datée (2026-06-03, motif
|
||||||
|
« Suppression du contact inline du Supplier — alignement refonte-contact M1 »).
|
||||||
|
- Encadré « Décision » renvoyant au `README.md` du dossier `refonte-contact`.
|
||||||
|
- Garder le style des specs M2.
|
||||||
|
|
||||||
|
## 5. Lien avec les tickets M2 existants
|
||||||
|
|
||||||
|
La mise à jour des specs doit être cohérente avec l'**amendement des tickets M2** (voir
|
||||||
|
`M2-amendement-tickets.md`) : tickets back #85/#86/#87/#91/#92 et front #94/#95/#96 (+ #93/#97
|
||||||
|
légers). Specs et tickets décrivent le **même** modèle cible (sans contact inline).
|
||||||
|
|
||||||
|
## 6. Critères d'acceptation (DoD)
|
||||||
|
|
||||||
|
- [ ] `spec-back.md` M2 : aucune mention des 5 colonnes inline dans le modèle `supplier`
|
||||||
|
(E-R + SQL + entité + payloads + sérialisation) ; RG-2.01/2.02 marquées supprimées ;
|
||||||
|
D1/D2 décrites.
|
||||||
|
- [ ] `spec-front.md` M2 : formulaire principal sans champs de contact ; onglet Contact
|
||||||
|
présenté comme seul lieu de saisie (sans pré-remplissage depuis le principal).
|
||||||
|
- [ ] Versions bumpées + historique daté.
|
||||||
|
- [ ] Cohérence avec l'amendement des tickets M2.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Prompt d'implémentation — M2 · Ticket Specs
|
||||||
|
|
||||||
|
Projet **Starseed**. Tâche **documentaire**. Mettre à jour les specs M2 Fournisseurs
|
||||||
|
(`docs/specs/M2-suppliers/spec-back.md` + `spec-front.md`) pour retirer le contact principal
|
||||||
|
inline du `Supplier` (5 champs `firstName/lastName/phonePrimary/phoneSecondary/email`).
|
||||||
|
|
||||||
|
M2 n'est **pas encore codé** : on corrige la conception en amont, **sans** migration ni
|
||||||
|
backfill (contrairement à M1). Les contacts vivent uniquement dans `SupplierContact`.
|
||||||
|
|
||||||
|
Spec du ticket : `docs/specs/M1-clients/refonte-contact/M2-ticket-specs.md` (lis-la + le
|
||||||
|
`README.md` du dossier).
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
|
||||||
|
1. Lire `spec-back.md` et `spec-front.md` M2 ; repérer toutes les occurrences des 5 champs
|
||||||
|
(E-R l.175-179, CREATE TABLE supplier l.227-231, entité l.496-517, payloads l.782-805 /
|
||||||
|
867-871, sérialisation l.725-729, RG-2.01/2.02/2.12, recherche, export, formulaire
|
||||||
|
principal front l.105-117, pré-remplissage onglet Contact l.142).
|
||||||
|
2. Retirer les 5 colonnes du modèle `supplier`.
|
||||||
|
3. Marquer **supprimées** RG-2.01 et RG-2.02 (renvoi RG-2.04/RG-2.13) ; restreindre RG-2.12
|
||||||
|
à `companyName` + `SupplierContact`. Ne pas renuméroter.
|
||||||
|
4. Refléter D1 (recherche : LEFT JOIN supplier_contact recommandé) et D2 (export depuis le
|
||||||
|
contact principal recommandé).
|
||||||
|
5. Front : retirer les champs de contact du formulaire principal ; retirer la phrase de
|
||||||
|
pré-remplissage du 1er bloc Contact ; présenter l'onglet Contact comme seul lieu de saisie.
|
||||||
|
6. Bumper la version + historique daté (2026-06-03).
|
||||||
|
|
||||||
|
## Garde-fous
|
||||||
|
|
||||||
|
- Uniquement les `.md` de specs M2. Style existant conservé.
|
||||||
|
- Cohérence stricte avec l'amendement des tickets M2 et avec la décision M1 (jumeau).
|
||||||
|
|
||||||
|
## Vérification
|
||||||
|
|
||||||
|
Relire les 2 specs : plus aucune mention des 5 champs inline dans le modèle `supplier` ;
|
||||||
|
RG-2.01/2.02 supprimées ; versions bumpées.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Refonte « contact » — suppression du contact inline des tiers (Client M1 + Supplier M2)
|
||||||
|
|
||||||
|
> Dossier de tickets transverse. Source de vérité de la décision et de son découpage.
|
||||||
|
> Rédigé le 2026-06-03. Owner : Matthieu.
|
||||||
|
|
||||||
|
## 1. Décision
|
||||||
|
|
||||||
|
Le **contact « principal » inline** (les 5 colonnes plates `first_name`, `last_name`,
|
||||||
|
`phone_primary`, `phone_secondary`, `email`) est **supprimé de l'entité tier** (`Client`,
|
||||||
|
puis `Supplier`). La gestion des contacts passe **exclusivement** par la sous-entité
|
||||||
|
dédiée (`ClientContact` / `SupplierContact`), c.-à-d. l'**onglet « Contacts »**.
|
||||||
|
|
||||||
|
### Pourquoi
|
||||||
|
|
||||||
|
- **Modèle unique, zéro duplication.** Aujourd'hui le contact est saisi deux fois : une
|
||||||
|
fois dans le bloc principal (inline sur le tier) et une fois dans l'onglet Contacts
|
||||||
|
(sous-entité). À la création, le front recopie même l'un dans l'autre
|
||||||
|
(`prefillFirstContact`). Deux sources pour la même information = risque de divergence.
|
||||||
|
- **Cohérence métier.** Un tier peut avoir plusieurs contacts ; il n'y a pas de raison
|
||||||
|
qu'un seul soit « privilégié » au niveau de la table tier. La notion de contact
|
||||||
|
appartient à la collection de contacts.
|
||||||
|
- **Garantie préservée.** L'invariant « il y a toujours au moins un contact » est déjà
|
||||||
|
assuré par la sous-entité : RG-1.05/RG-1.14 (M1) et RG-2.04/RG-2.13 (M2) imposent
|
||||||
|
**≥ 1 bloc Contact valide** (Nom OU Prénom). Supprimer le contact inline ne crée donc
|
||||||
|
aucun trou : le contact reste obligatoire, mais au bon endroit.
|
||||||
|
|
||||||
|
### Règles de gestion impactées
|
||||||
|
|
||||||
|
| RG | Avant | Après |
|
||||||
|
|---|---|---|
|
||||||
|
| RG-1.01 / RG-2.01 (firstName OU lastName obligatoire **sur le tier**) | sur `Client` / `Supplier` | **supprimée** du tier — équivalent assuré par RG-1.05 / RG-2.04 sur la sous-entité |
|
||||||
|
| RG-1.02 / RG-2.02 (max 2 téléphones **sur le tier**) | sur le tier | **supprimée** du tier — reste sur la sous-entité |
|
||||||
|
| RG-1.19/1.20/1.21 — RG-2.12 (normalisation Capitalize / chiffres / lowercase) | appliquée aux champs **du tier ET** de la sous-entité | ne s'applique plus aux champs du tier (qui n'existent plus) — **inchangée** sur la sous-entité |
|
||||||
|
|
||||||
|
## 2. Périmètre & découpage
|
||||||
|
|
||||||
|
### M1 — Clients (code DÉJÀ livré → suppression + migration de données)
|
||||||
|
|
||||||
|
| # | Ticket | Tag | Effort |
|
||||||
|
|---|--------|-----|--------|
|
||||||
|
| 1 | `M1-ticket-01-back` — supprimer le contact inline du `Client` (migration + backfill + entité + processor + provider + export + fixtures + tests) | Backend | M |
|
||||||
|
| 2 | `M1-ticket-02-front` — retirer le bloc contact principal des écrans création / consultation / modification | Frontend | M |
|
||||||
|
| 3 | `M1-ticket-03-specs` — acter la décision dans les specs M1 (back + front + cahier de test) | Maintenance | S |
|
||||||
|
|
||||||
|
### M2 — Fournisseurs (NON codé → on retire le contact inline dès la conception)
|
||||||
|
|
||||||
|
| # | Action | Tag | Effort |
|
||||||
|
|---|--------|-----|--------|
|
||||||
|
| 4 | `M2-ticket-specs` — mettre à jour les specs M2 déjà écrites (back + front) pour retirer le contact inline du `Supplier` | Maintenance | S |
|
||||||
|
| — | `M2-amendement-tickets` — amender les tickets M2 existants (n° 84–97) impactés (migration, entités, processor, export, front, tests, i18n) | — | — |
|
||||||
|
|
||||||
|
> M2 ne nécessite **pas** de migration de suppression ni de backfill : il suffit de **ne
|
||||||
|
> jamais créer** les 5 colonnes inline sur `supplier`. Le travail M2 est donc un
|
||||||
|
> ajustement de specs + un amendement des tickets « prêts à dev ».
|
||||||
|
|
||||||
|
## 3. Décisions transverses à trancher (mêmes pour M1 et M2)
|
||||||
|
|
||||||
|
Deux comportements s'appuyaient sur les colonnes inline du tier. À la suppression, il faut
|
||||||
|
choisir leur nouvelle source. Recommandation par défaut entre parenthèses.
|
||||||
|
|
||||||
|
- **D1 — Recherche serveur** (`?search=`). Aujourd'hui : fuzzy sur `companyName` +
|
||||||
|
`lastName` + `email` **du tier**. Après suppression, deux options :
|
||||||
|
- (a) restreindre la recherche à `companyName` seul (simple, mais perte de la recherche
|
||||||
|
par contact) ;
|
||||||
|
- (b) **[recommandé]** étendre la recherche en `LEFT JOIN` sur la sous-entité contact
|
||||||
|
(`first_name` / `last_name` / `email` du contact), pour préserver l'UX « recherche par
|
||||||
|
nom / contact / email » annoncée dans la barre de recherche.
|
||||||
|
- **D2 — Colonnes de l'export XLSX** (Nom contact / Prénom / Téléphone / Téléphone 2 /
|
||||||
|
Email). Après suppression :
|
||||||
|
- (a) supprimer ces colonnes ;
|
||||||
|
- (b) **[recommandé]** les alimenter depuis le **contact principal** (le contact de plus
|
||||||
|
petit `position`), pour garder un export utile.
|
||||||
|
|
||||||
|
Ces deux décisions sont à valider par le métier (Matthieu) avant implémentation et sont
|
||||||
|
rappelées dans chaque ticket concerné.
|
||||||
|
|
||||||
|
## 4. Fichiers de ce dossier
|
||||||
|
|
||||||
|
- `README.md` (ce fichier) — décision + découpage.
|
||||||
|
- `M1-ticket-01-back.md` / `.prompt.md` — description + prompt d'implémentation.
|
||||||
|
- `M1-ticket-02-front.md` / `.prompt.md`.
|
||||||
|
- `M1-ticket-03-specs.md` / `.prompt.md`.
|
||||||
|
- `M2-ticket-specs.md` / `.prompt.md`.
|
||||||
|
- `M2-amendement-tickets.md` — bandeau d'amendement + liste des tickets M2 à mettre à jour.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,287 @@
|
|||||||
|
---
|
||||||
|
# === IDENTITÉ ===
|
||||||
|
module: M1
|
||||||
|
nom: "Répertoire clients"
|
||||||
|
ecran: repertoire-clients
|
||||||
|
owner_spec: Matthieu
|
||||||
|
backup_spec: Tristan
|
||||||
|
version: V1
|
||||||
|
# Historique : V1 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline
|
||||||
|
# (Nom/Prénom/Téléphone/Téléphone 2/Email retirés du formulaire principal et des écrans).
|
||||||
|
# Saisie via l'onglet Contacts uniquement. Cf. docs/specs/M1-clients/refonte-contact/README.md
|
||||||
|
date_redaction: 2026-05-28
|
||||||
|
|
||||||
|
# === LIENS ===
|
||||||
|
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
|
||||||
|
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
|
||||||
|
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||||
|
lien_spec_back: ./spec-back.md
|
||||||
|
|
||||||
|
# === VALIDATION CLIENT #1 ===
|
||||||
|
client_validation_1:
|
||||||
|
statut: validee
|
||||||
|
date: 2026-05-22
|
||||||
|
canal: ecrit
|
||||||
|
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
|
||||||
|
resume: "Module 1 — Répertoire clients. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders blancs)."
|
||||||
|
trace_archivee: "uploads/4a1b026f-M1-reportoire-clients.docx (V0 d'origine .docx)"
|
||||||
|
|
||||||
|
# === LIEN LESSTIME ===
|
||||||
|
lesstime_taskgroup_id: 23
|
||||||
|
lesstime_project_id: 6
|
||||||
|
statut_global: en_dev
|
||||||
|
---
|
||||||
|
|
||||||
|
# Module 1 — Répertoire clients (V0 front)
|
||||||
|
|
||||||
|
> **Origine** : spec front V0 livrée le 22/05/2026 (`M1-reportoire-clients.docx`). Restitution Markdown pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute précision et toute décision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
|
||||||
|
|
||||||
|
## But
|
||||||
|
|
||||||
|
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des clients** de l'organisation : consultation, création, modification, archivage. Cette page est la **porte d'entrée du module Commercial**.
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire clients »
|
||||||
|
- **Rôles autorisés** :
|
||||||
|
|
||||||
|
| Rôle | Consultation | Création / Modification | Archivage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||||
|
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
|
||||||
|
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
|
||||||
|
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
|
||||||
|
| **Usine** | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
> **Note** : aligné sur le docx d'origine — Compta édite uniquement l'onglet Comptabilité (champs SIREN / TVA / Délai de règlement / Type de règlement / Banque / RIBs). Compta ne peut pas **créer** un client (pas de droit `manage` général), mais peut éditer la partie comptable d'un client existant créé par Admin ou Bureau.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
L'écran est la page d'entrée du module **Commercial**. Titre : « **Répertoire clients** ».
|
||||||
|
|
||||||
|
- Affichage principal : un **datatable** listant tous les clients **actifs** de l'organisation (les clients archivés sont masqués par défaut — filtre UI dédié pour les voir).
|
||||||
|
- **Clic sur une ligne** → bascule sur l'écran **Consultation client** (page dédiée, pas un drawer — cf. maquette Figma).
|
||||||
|
- **Bouton « + Ajouter »** (en haut à droite) → bascule sur l'écran **Ajouter un client**.
|
||||||
|
- **Bouton « Exporter »** (en haut à droite) → télécharge un **fichier XLSX** des clients **affichés** (cf. filtre actif). Format détaillé dans [`spec-back.md` § Export](./spec-back.md).
|
||||||
|
|
||||||
|
## Datatable du Répertoire
|
||||||
|
|
||||||
|
Composant : `<MalioDataTable>`. Colonnes (à raffiner avec Tristan en revue maquette) :
|
||||||
|
|
||||||
|
| Colonne | Source | Tri |
|
||||||
|
|---|---|---|
|
||||||
|
| **Nom entreprise** | `client.companyName` | ASC par défaut |
|
||||||
|
| **Catégories** | liste des codes catégories séparés par `,` | Non |
|
||||||
|
| **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non |
|
||||||
|
|
||||||
|
> **Filtre archivés** : toggle UI en haut du datatable. Désactivé par défaut. État local (pas dans l'URL — cf. règle ABSOLUE Starseed n°6).
|
||||||
|
|
||||||
|
> **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible — quelques centaines). Tri serveur `companyName ASC` par défaut.
|
||||||
|
|
||||||
|
## Écran « Ajouter un client »
|
||||||
|
|
||||||
|
Création par **onglets successifs avec validation incrémentale** : pour pouvoir passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant**, et les champs de l'onglet validé passent en lecture seule + bouton « Valider » désactivé (disabled).
|
||||||
|
|
||||||
|
### Formulaire principal (pré-onglets)
|
||||||
|
|
||||||
|
C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles.
|
||||||
|
|
||||||
|
> **V1 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent désormais dans l'onglet **Contacts** (RG-1.05 / RG-1.14). Le formulaire principal ne contient plus que Entreprise + Catégorie + relation Distributeur/Courtier.
|
||||||
|
|
||||||
|
| Champ | Type composant | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
|
||||||
|
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
|
||||||
|
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
|
||||||
|
| **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de **code** `DISTRIBUTEUR` (ERP-78), via `GET /api/clients?categoryCode=DISTRIBUTEUR`. RG-1.03. |
|
||||||
|
| **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de **code** `COURTIER` (ERP-78), via `GET /api/clients?categoryCode=COURTIER`. RG-1.03. |
|
||||||
|
| **Prestation de triage** | `<MalioCheckbox>` | Non | — |
|
||||||
|
|
||||||
|
**Action** : « Valider » (`<MalioButton>`) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ».
|
||||||
|
|
||||||
|
### Onglet « Information »
|
||||||
|
|
||||||
|
Saisir les informations de l'entreprise.
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) |
|
||||||
|
| **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||||
|
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 |
|
||||||
|
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 |
|
||||||
|
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||||
|
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||||
|
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||||
|
|
||||||
|
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
|
||||||
|
|
||||||
|
### Onglet « Contact »
|
||||||
|
|
||||||
|
Saisir un ou plusieurs contacts associés au client. **(V1 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-1.14).
|
||||||
|
|
||||||
|
**Bloc Contact** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Nom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||||
|
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||||
|
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||||
|
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-1.20 (format) |
|
||||||
|
| **Email** | `<MalioInputText>` type email | Non | RG-1.21 (lowercase) |
|
||||||
|
|
||||||
|
**RG-1.14 (renforcement validée par Tristan le 28/05)** : **au moins 1 bloc Contact valide** (au moins Nom OU Prénom rempli) est obligatoire pour valider l'onglet. Donc l'onglet Contact ne peut pas être finalisé vide.
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + Nouveau contact » : ajoute un bloc. Bouton **désactivé tant que le bloc précédent n'a pas Prénom OU Nom rempli** (RG-1.05).
|
||||||
|
- « Supprimer » (icône) sur un bloc : modal de confirmation (`<MalioButton>` Annuler / Confirmer). Si Oui → suppression du bloc.
|
||||||
|
- « Valider » → PATCH `/api/clients/{id}/contacts` (création/mise à jour de la collection).
|
||||||
|
|
||||||
|
### Onglet « Adresse »
|
||||||
|
|
||||||
|
Saisir une ou plusieurs adresses du client, rattachées à un ou plusieurs sites Starseed (Châtellerault 86 / Saint-Jean 17 / Pommevic 82) et à des contacts.
|
||||||
|
|
||||||
|
**Bloc Adresse** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Prospect** | `<MalioCheckbox>` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché |
|
||||||
|
| **Adresse de livraison** | `<MalioCheckbox>` | Non | RG-1.07 — masque Prospect si coché |
|
||||||
|
| **Facturation** | `<MalioCheckbox>` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) |
|
||||||
|
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` **hors codes `DISTRIBUTEUR` / `COURTIER`** (ERP-78 — ces codes qualifient une relation entre clients, pas un lieu). Le front exclut ces 2 codes du select (le `code` est exposé en lecture sur `/api/categories`). |
|
||||||
|
| **Pays** | `<MalioSelect>` | Oui | Préremplie « France » |
|
||||||
|
| **Code postal** | `<MalioInputText>` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN |
|
||||||
|
| **Ville** | `<MalioSelect>` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP |
|
||||||
|
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-1.09 — autocomplete BAN |
|
||||||
|
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||||
|
| **Sites Starseed** | `<MalioSelectCheckbox>` (multi-checkbox 86 / 17 / 82) | Oui | RG-1.10 — ≥ 1 site obligatoire |
|
||||||
|
| **Contact(s) rattaché(s)** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||||
|
| **Email (facturation)** | `<MalioInputText>` type email | Conditionnel | RG-1.11 — visible/obligatoire uniquement si « Facturation » coché |
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + Nouvelle Adresse » : ajoute un bloc identique.
|
||||||
|
- « Supprimer » : modal de confirmation puis suppression.
|
||||||
|
- « Valider » → PATCH `/api/clients/{id}/addresses`.
|
||||||
|
|
||||||
|
### Onglet « Transport »
|
||||||
|
|
||||||
|
🚧 **Placeholder blanc au M1.** Frame vide. Aucun champ. Aucun bouton de validation. L'utilisateur passe automatiquement à l'onglet suivant. **Pas de mention « En cours »** — c'est juste blanc (décision Tristan 28/05).
|
||||||
|
|
||||||
|
### Onglet « Comptabilité »
|
||||||
|
|
||||||
|
⚠ **Accessible aux rôles avec `commercial.clients.accounting.manage`** (Admin + Compta au M1). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer cet onglet** (champs SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) — cf. décision Q1, aligné docx. Compta ne peut pas créer un client (pas de `manage` général).
|
||||||
|
|
||||||
|
**Champs comptables** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | Format 9 chiffres. **Pas d'unicité** (décision Q4) |
|
||||||
|
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||||
|
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` |
|
||||||
|
| **N° de TVA** | `<MalioInputText>` | Oui | — |
|
||||||
|
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
|
||||||
|
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
|
||||||
|
| **Banque** | `<MalioSelect>` | Conditionnel | RG-1.12 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks`. |
|
||||||
|
|
||||||
|
**Bloc RIB** (0..n blocs, présence obligatoire conditionnée par RG-1.13) :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 |
|
||||||
|
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||||
|
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + RIB » : ajoute un bloc.
|
||||||
|
- « Supprimer » (icône) : modal de confirmation.
|
||||||
|
- « Valider » → PATCH `/api/clients/{id}/accounting`.
|
||||||
|
|
||||||
|
### Onglets « Statistiques » / « Rapports » / « Échanges »
|
||||||
|
|
||||||
|
🚧 **Placeholders blancs au M1.** Mêmes règles que Transport (frames vides, pas de validation).
|
||||||
|
|
||||||
|
## Écran « Consultation client »
|
||||||
|
|
||||||
|
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans bouton `+` pour ajouter des blocs Contact / Adresse / RIB.
|
||||||
|
|
||||||
|
- **Flèche retour** (à gauche) → revient au Répertoire.
|
||||||
|
- **Bouton « Modifier »** (à droite, visible si l'utilisateur a la permission `commercial.clients.manage`) → bascule sur l'écran Modification.
|
||||||
|
- **Bouton « Archiver »** (à droite, visible **uniquement pour Admin** via permission `commercial.clients.archive`) → ouvre une modal de confirmation, puis PATCH `/api/clients/{id}` `{ "isArchived": true }`. Le client passe en archivé (cf. flag `is_archived`).
|
||||||
|
|
||||||
|
> Le client archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé. Décision validée Tristan 28/05.
|
||||||
|
|
||||||
|
### Onglets affichés en consultation
|
||||||
|
|
||||||
|
Mêmes onglets qu'en création, **plus** les 4 placeholders blancs. L'utilisateur navigue librement entre les onglets (pas de séquence forcée en consultation).
|
||||||
|
|
||||||
|
## Écran « Modification client »
|
||||||
|
|
||||||
|
Comportement identique à l'écran Ajouter sauf :
|
||||||
|
- **Pas de formulaire principal** (les champs principaux sont édités via les onglets correspondants).
|
||||||
|
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||||
|
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||||
|
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` restent en lecture seule (pas de bouton Valider, pas d'icône suppression de bloc).
|
||||||
|
- Les onglets placeholders restent inaccessibles à l'édition (blancs).
|
||||||
|
|
||||||
|
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||||
|
|
||||||
|
- **Datatable** : `<MalioDataTable>` (Répertoire)
|
||||||
|
- **Input texte** : `<MalioInputText>`
|
||||||
|
- **Input numérique** : `<MalioInputNumber>`
|
||||||
|
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
|
||||||
|
- **TextArea** : `<MalioInputTextArea>` (Description)
|
||||||
|
- **Select simple** : `<MalioSelect>` (Pays, Ville, distributeur/courtier, refs comptables)
|
||||||
|
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||||
|
- **Checkbox** : `<MalioCheckbox>` (Prospect, Adresse livraison, Facturation, Prestation de triage)
|
||||||
|
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||||
|
- **Toasts** : standards via `useApi()`
|
||||||
|
|
||||||
|
**Exceptions autorisées** (à commenter `// TODO migrer quand Malio couvre`) :
|
||||||
|
- `<input type="date">` pour « Date de création » (composant `MalioDate` non couvert)
|
||||||
|
- Modal de confirmation : composant à confirmer côté équipe front (probablement `<MalioModal>` ou un wrapper à créer dans `frontend/shared/`)
|
||||||
|
|
||||||
|
## Règles de formatage et normalisation
|
||||||
|
|
||||||
|
Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.md`](./spec-back.md)) :
|
||||||
|
|
||||||
|
| Champ | Normalisation serveur | Affichage front |
|
||||||
|
|---|---|---|
|
||||||
|
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||||
|
| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique |
|
||||||
|
| Téléphone (téléphones des blocs `ClientContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
|
||||||
|
| Email | lowercase intégral | identique |
|
||||||
|
|
||||||
|
> **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`.
|
||||||
|
|
||||||
|
## API adresse postale
|
||||||
|
|
||||||
|
Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.data.gouv.fr** (Base Adresse Nationale, gratuite, française).
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. Refonte ERP-78 : type unique `CLIENT` ; `Distributeur`/`Courtier`/`Secteur`/`Autre` (+ catégories métier) sont des `Category` portant un `code` stable (HP-3 du M0 levé). |
|
||||||
|
| 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. |
|
||||||
|
| 3 | Onglet « Comptabilité » : qui édite ? | **Admin et Compta** peuvent éditer l'onglet Comptabilité (`commercial.clients.accounting.manage`). Bureau / Commerciale ne voient pas l'onglet. Compta ne peut pas créer un client (pas de `manage` global), mais peut éditer la partie comptable d'un client existant. |
|
||||||
|
| 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». |
|
||||||
|
| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. |
|
||||||
|
| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. |
|
||||||
|
| 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. |
|
||||||
|
| 8 | Téléphones (max 2) | Sur les blocs `ClientContact` (`phone_primary` + `phone_secondary`). _(V1 : retirés du Client — refonte-contact.)_ |
|
||||||
|
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. |
|
||||||
|
| 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). |
|
||||||
|
| 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Tickets Lesstime générés
|
||||||
|
|
||||||
|
**TaskGroup Lesstime** : à créer — `M1 — Répertoire clients` (projet `ERP / Starseed`, projectId=6).
|
||||||
|
|
||||||
|
> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
|||||||
|
---
|
||||||
|
# === IDENTITÉ ===
|
||||||
|
module: M2
|
||||||
|
nom: "Répertoire fournisseurs"
|
||||||
|
ecran: repertoire-fournisseurs
|
||||||
|
owner_spec: Matthieu
|
||||||
|
backup_spec: Tristan
|
||||||
|
version: V0.2
|
||||||
|
date_redaction: 2026-06-02
|
||||||
|
# Historique : V0.2 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline
|
||||||
|
# du formulaire Supplier (Nom/Prénom/Téléphone/Téléphone 2/Email). Saisie via l'onglet Contacts.
|
||||||
|
# Aligné sur M1. Cf. docs/specs/M1-clients/refonte-contact/README.md
|
||||||
|
|
||||||
|
# === LIENS ===
|
||||||
|
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev"
|
||||||
|
regles_metier: [RG-2.01, RG-2.02, RG-2.03, RG-2.04, RG-2.05, RG-2.06, RG-2.07, RG-2.08, RG-2.09, RG-2.10, RG-2.11, RG-2.12, RG-2.13, RG-2.14, RG-2.15, RG-2.16, RG-2.17]
|
||||||
|
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||||
|
lien_spec_back: ./spec-back.md
|
||||||
|
|
||||||
|
# === VALIDATION CLIENT ===
|
||||||
|
client_validation_1:
|
||||||
|
statut: validee
|
||||||
|
date: 2026-05-22
|
||||||
|
version: V0
|
||||||
|
valide_par: "Matthieu (CP MALIO)"
|
||||||
|
client_validation_2:
|
||||||
|
statut: validee
|
||||||
|
date: 2026-06-01
|
||||||
|
version: V0.1
|
||||||
|
valide_par: "Matthieu (CP MALIO)"
|
||||||
|
resume: "Module 2 — Répertoire fournisseurs. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders 'À venir')."
|
||||||
|
trace_archivee: "uploads/M2-reportoire-fournisseurs.docx (V0.1) + M2-reportoire-fournisseurs-V01.pdf"
|
||||||
|
|
||||||
|
# === LIEN LESSTIME ===
|
||||||
|
lesstime_taskgroup_id: 26
|
||||||
|
lesstime_project_id: 6
|
||||||
|
statut_global: a_dev
|
||||||
|
---
|
||||||
|
|
||||||
|
# Module 2 — Répertoire fournisseurs (V0.1 front)
|
||||||
|
|
||||||
|
> **Origine** : spec front livrée le 22/05/2026 (V0), amendée le 01/06/2026 (V0.1) — `M2-reportoire-fournisseurs.docx`. Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié ; toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M2 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md).
|
||||||
|
|
||||||
|
## But
|
||||||
|
|
||||||
|
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des fournisseurs** de l'organisation : consultation, création, modification, archivage. C'est la **deuxième porte d'entrée du module Commercial** (aux côtés des Clients).
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire fournisseurs » (route `/suppliers`).
|
||||||
|
- **Rôles autorisés** :
|
||||||
|
|
||||||
|
| Rôle | Consultation | Création / Modification | Archivage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||||
|
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
|
||||||
|
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
|
||||||
|
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
|
||||||
|
| **Usine** | ❌ (pas d'accès) | ❌ | ❌ |
|
||||||
|
|
||||||
|
> **Note** : RBAC identique au M1, transposée sur `commercial.suppliers.*`. Compta édite uniquement l'onglet Comptabilité (SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) d'un fournisseur existant ; Compta ne peut pas **créer** un fournisseur. **L'archivage est réservé à Admin** (cf. tableau du docx).
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Page d'entrée du module **Commercial** (route `/suppliers`). Titre : « **Répertoire fournisseurs** ».
|
||||||
|
|
||||||
|
- Affichage principal : un **datatable** listant tous les fournisseurs **actifs** (les archivés sont masqués par défaut — toggle UI dédié).
|
||||||
|
- **Clic sur une ligne** → écran **Consultation fournisseur** (page dédiée).
|
||||||
|
- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un fournisseur**.
|
||||||
|
- **Bouton « Filtrer »** (haut droite, **à côté de « + Ajouter »**) → ouvre le **panneau de filtres** (cf. ci-dessous). Un badge/compteur indique le nombre de filtres actifs ; un bouton « Réinitialiser » les vide.
|
||||||
|
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des fournisseurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
|
||||||
|
|
||||||
|
### Panneau de filtres (bouton « Filtrer »)
|
||||||
|
|
||||||
|
Ouvre un drawer/popover (composant à confirmer côté équipe front — réutiliser le pattern M1 s'il existe). Filtres proposés, branchés sur les query params de `GET /api/suppliers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
|
||||||
|
|
||||||
|
| Filtre | Composant | Query param back |
|
||||||
|
|---|---|---|
|
||||||
|
| **Recherche** (nom entreprise / contact / email — recherche contact via `supplier_contact`, décision D1) | `<MalioInputText>` | `?search=` |
|
||||||
|
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type FOURNISSEUR) | `?categoryCode=` |
|
||||||
|
| **Site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | `?siteId=` |
|
||||||
|
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
|
||||||
|
|
||||||
|
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/suppliers` avec les params.
|
||||||
|
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6). Le bouton « Filtrer » + son panneau remplacent/regroupent l'ancien toggle « archivés » isolé.
|
||||||
|
|
||||||
|
## Datatable du Répertoire
|
||||||
|
|
||||||
|
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Supplier>({ url: '/suppliers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes **conformes à la maquette Figma** (4 colonnes) :
|
||||||
|
|
||||||
|
| Colonne | Source | Tri |
|
||||||
|
|---|---|---|
|
||||||
|
| **Nom** | `supplier.companyName` | ASC par défaut |
|
||||||
|
| **Catégories** | `supplier.categories[].name` (embarquées en liste — cohérence M1/ERP-62 ; libellé = `name`, pas `label`) | Non |
|
||||||
|
| **Site** | `supplier.sites[].name` (agrégat des adresses via `getSites()` ; `Site` n'a pas de `code`) | Non |
|
||||||
|
| **Dernière activité** | `supplier.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `supplier:read` | Oui |
|
||||||
|
|
||||||
|
> **Clic sur une ligne** (texte en bleu lien) → écran Consultation.
|
||||||
|
> **Filtres** : regroupés dans le panneau du bouton « Filtrer » (cf. section précédente), dont l'inclusion des archivés (désactivée par défaut). **État local** (jamais dans l'URL — règle ABSOLUE n°6).
|
||||||
|
> **Pagination** : `<MalioDataTable>` + `usePaginatedList`, options **standard Starseed 10 / 25 / 50 (défaut 10)** — on **n'applique pas** le « Ligne : 20 » de la maquette (décision Matthieu : on reste sur le standard). Tri serveur `companyName ASC` par défaut.
|
||||||
|
|
||||||
|
## Écran « Ajouter un fournisseur »
|
||||||
|
|
||||||
|
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
|
||||||
|
|
||||||
|
**Barre d'onglets en création (5 onglets, conforme maquette)** : `Information` · `Contacts` · `Adresses` · `Transport` · `Comptabilité`. L'onglet `Information` est actif par défaut juste après validation du formulaire principal. Les onglets `Statistiques`, `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification.
|
||||||
|
|
||||||
|
### Formulaire principal (pré-onglets)
|
||||||
|
|
||||||
|
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/suppliers`, puis bascule sur l'onglet Information ; les champs passent en readonly.
|
||||||
|
|
||||||
|
> **V0.2 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent dans l'onglet **Contacts** (RG-2.04 / RG-2.13). Le formulaire principal ne contient plus que Entreprise + Catégorie.
|
||||||
|
|
||||||
|
| Champ | Type composant | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Nom du fournisseur (Entreprise)** | `<MalioInputText>` | Oui | RG-2.12 (UPPERCASE serveur) |
|
||||||
|
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | `Category` de **type FOURNISSEUR** via `GET /api/categories?typeCode=FOURNISSEUR` (RG-2.10). Libellé affiché = `category.name`. ⚠️ Le type + le filtre `?typeCode=` sont **à créer** côté back (n'existent pas en prod — cf. spec-back § 2.4). |
|
||||||
|
|
||||||
|
**Action** : « Valider » (`<MalioButton>`) → POST `/api/suppliers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Information ».
|
||||||
|
|
||||||
|
### Onglet « Information »
|
||||||
|
|
||||||
|
Saisir les informations du fournisseur.
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-2.03 (obligatoire rôle Commerciale) |
|
||||||
|
| **Concurrent** | `<MalioInputText>` | Conditionnel | RG-2.03 |
|
||||||
|
| **Date création** (entreprise) | `<input type="date">` (exception Malio — `// TODO migrer`) | Conditionnel | RG-2.03 |
|
||||||
|
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-2.03 |
|
||||||
|
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
|
||||||
|
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-2.03 |
|
||||||
|
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
|
||||||
|
| **Volume Prévisionnel** | `<MalioInputNumber>` | Conditionnel | RG-2.03 (champ spécifique fournisseur) |
|
||||||
|
|
||||||
|
> **Disposition maquette** : 3 colonnes — ligne 1 (Description / Concurrent / Date création), ligne 2 (Nombre de salariés / CA / Dirigeant), ligne 3 (Résultat / Volume Prévisionnel).
|
||||||
|
|
||||||
|
**Action** : « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:information`).
|
||||||
|
|
||||||
|
### Onglet « Contact »
|
||||||
|
|
||||||
|
Saisir un ou plusieurs contacts. **(V0.2 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-2.13).
|
||||||
|
|
||||||
|
**Bloc Contact** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Nom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
|
||||||
|
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
|
||||||
|
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||||
|
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-2.12 (format) |
|
||||||
|
| **Email** | `<MalioInputText>` type email | Non | RG-2.12 (lowercase) |
|
||||||
|
|
||||||
|
**RG-2.04 / RG-2.13** : au moins 1 bloc Contact valide (Nom OU Prénom rempli) pour valider l'onglet — l'onglet Contact ne peut pas être finalisé vide.
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas Prénom OU Nom** (RG-2.04).
|
||||||
|
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
|
||||||
|
- « Valider » → PATCH `/api/suppliers/{id}/contacts`.
|
||||||
|
|
||||||
|
### Onglet « Adresse »
|
||||||
|
|
||||||
|
Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts.
|
||||||
|
|
||||||
|
**Bloc Adresse** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Type d'adresse** | `<MalioRadioButton>` — `Prospect` / `Départ` / `Rendu` | Oui | RG-2.09 (exclusif, enum `PROSPECT`/`DEPART`/`RENDU`) |
|
||||||
|
| **Pays** | `<MalioSelect>` (saisie assistée — préremplie « France ») | Oui | — |
|
||||||
|
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — déclenche autocomplete ville (BAN) |
|
||||||
|
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-2.05 — alimentée par api-adresse.data.gouv.fr suivant le CP |
|
||||||
|
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — autocomplete BAN |
|
||||||
|
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||||
|
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-2.06 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100/17400/82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M). |
|
||||||
|
| **Catégories** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type FOURNISSEUR (RG-2.10), liées aux catégories du fournisseur |
|
||||||
|
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||||
|
| **Benne(s)** | `<MalioInputNumber>` (stepper −/+ , défaut 0) | Non | Champ spécifique fournisseur |
|
||||||
|
| **Prestation de triage** | `<MalioCheckbox>` | Non | Champ spécifique fournisseur (porté par l'adresse — colonne back `triage_provider`) |
|
||||||
|
|
||||||
|
> **Disposition maquette par bloc** : ligne 1 = radio (Prospect / Départ / Rendu) + Pays + Code postal ; ligne 2 = Ville + Adresse + Adresse complémentaire ; ligne 3 = sites (86 / 17 / 82) + Catégories + Contact ; ligne 4 = Benne(s) + Prestation de triage. Icône corbeille en haut à droite de chaque bloc pour le supprimer.
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
|
||||||
|
- « Supprimer » : modal de confirmation puis suppression.
|
||||||
|
- « Valider » → PATCH `/api/suppliers/{id}/addresses`.
|
||||||
|
|
||||||
|
### Onglet « Transport »
|
||||||
|
|
||||||
|
🚧 **Onglet placeholder minimal au M2.** Conforme à la maquette : la frame est **vide** (aucun champ, aucun bouton de validation, aucune API back). L'onglet reste navigable. Un libellé discret « À venir » est toléré mais non requis (la maquette ne l'affiche pas). Cet onglet **fait partie de la barre de création** (entre Adresses et Comptabilité).
|
||||||
|
|
||||||
|
### Onglet « Comptabilité »
|
||||||
|
|
||||||
|
⚠ **Accessible aux rôles avec `commercial.suppliers.accounting.view`** (Admin + Compta au M2). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un fournisseur (pas de `manage` global).
|
||||||
|
|
||||||
|
**Champs comptables** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. § 2.6) |
|
||||||
|
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||||
|
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel M1) |
|
||||||
|
| **N° de TVA** | `<MalioInputText>` | Oui | — |
|
||||||
|
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
|
||||||
|
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
|
||||||
|
| **Banque** | `<MalioSelect>` | Conditionnel | RG-2.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). |
|
||||||
|
|
||||||
|
**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-2.08) :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
|
||||||
|
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
|
||||||
|
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + RIB » : ajoute un bloc.
|
||||||
|
- « Supprimer » (icône) : modal de confirmation.
|
||||||
|
- « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:accounting`) + sous-ressource RIBs.
|
||||||
|
|
||||||
|
### Onglets « Statistiques » / « Rapports » / « Échanges »
|
||||||
|
|
||||||
|
🚧 **Placeholders minimaux au M2 — uniquement en Consultation / Modification** (ils n'apparaissent **pas** dans le flux de création, cf. maquette). Frames vides, pas de validation, pas d'API.
|
||||||
|
|
||||||
|
## Écran « Consultation fournisseur »
|
||||||
|
|
||||||
|
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs.
|
||||||
|
|
||||||
|
- **Flèche retour** (gauche) → revient au Répertoire.
|
||||||
|
- **Bouton « Modifier »** (droite, visible si `commercial.suppliers.manage`) → écran Modification.
|
||||||
|
- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `commercial.suppliers.archive`) → modal de confirmation, puis PATCH `/api/suppliers/{id}` `{ "isArchived": true }`.
|
||||||
|
|
||||||
|
> Un fournisseur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
|
||||||
|
|
||||||
|
### Onglets affichés en consultation
|
||||||
|
|
||||||
|
Information / Contacts / Adresses / Transport / Statistiques / Rapports / Échanges / Comptabilité (les 4 derniers métiers en placeholder « À venir », Comptabilité selon permission). L'utilisateur navigue **librement** entre les onglets (pas de séquence forcée en consultation).
|
||||||
|
|
||||||
|
## Écran « Modification fournisseur »
|
||||||
|
|
||||||
|
Comportement identique à l'écran Ajouter sauf :
|
||||||
|
- **Pas de formulaire principal** réaffiché (champs principaux édités via les onglets correspondants).
|
||||||
|
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||||
|
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||||
|
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression).
|
||||||
|
- Les onglets placeholders « À venir » restent non éditables.
|
||||||
|
|
||||||
|
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||||
|
|
||||||
|
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||||
|
- **Input texte** : `<MalioInputText>`
|
||||||
|
- **Input numérique** : `<MalioInputNumber>` (Nombre de salariés, Volume prévisionnel, Bennes)
|
||||||
|
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
|
||||||
|
- **TextArea** : `<MalioInputTextArea>` (Description)
|
||||||
|
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
|
||||||
|
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||||
|
- **Radio** : `<MalioRadioButton>` (Type d'adresse Prospect / Départ / Rendu — RG-2.09)
|
||||||
|
- **Checkbox** : `<MalioCheckbox>` (Prestataire de triage)
|
||||||
|
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||||
|
- **Toasts** : standards via `useApi()`
|
||||||
|
|
||||||
|
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||||
|
- `<input type="date">` pour « Date Création » (`MalioDate` non couvert).
|
||||||
|
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1 si présent).
|
||||||
|
|
||||||
|
## Composables & appels API
|
||||||
|
|
||||||
|
- `usePaginatedList<Supplier>({ url: '/suppliers' })` — liste paginée (obligatoire, règle frontend). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cohérence M1/ERP-62, cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)). Côté back, fetch-joins anti-N+1.
|
||||||
|
- `useSupplier(id)` — charge le détail via `GET /api/suppliers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Les écrans Consultation et Modification se peuplent depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1 d'appels). **DoD avant intégration** : vérifier que le JSON réel contient bien ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||||
|
- `useSupplierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useClientForm()`.
|
||||||
|
- `useAddressAutocomplete()` — **réutilisé du M1** (BAN), pas de réécriture.
|
||||||
|
- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver.
|
||||||
|
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
|
||||||
|
- Filter `formatPhoneFR()` — **réutilisé du M1** pour l'affichage `XX XX XX XX XX`.
|
||||||
|
|
||||||
|
## Règles de formatage et normalisation
|
||||||
|
|
||||||
|
Le serveur normalise systématiquement (RG-2.12 — cf. [`spec-back.md`](./spec-back.md)) :
|
||||||
|
|
||||||
|
| Champ | Normalisation serveur | Affichage front |
|
||||||
|
|---|---|---|
|
||||||
|
| Nom fournisseur (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||||
|
| Nom + Prénom contact | Capitalize | identique |
|
||||||
|
| Téléphones (blocs `SupplierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
|
||||||
|
| Email | lowercase intégral | identique |
|
||||||
|
|
||||||
|
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche. Cohérent avec `useApi()`.
|
||||||
|
|
||||||
|
## API adresse postale
|
||||||
|
|
||||||
|
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1** (réutilisé tel quel) :
|
||||||
|
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville.
|
||||||
|
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
|
||||||
|
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
|
||||||
|
|
||||||
|
## Différences notables avec le M1 (clients)
|
||||||
|
|
||||||
|
| Zone | M1 clients | M2 fournisseurs |
|
||||||
|
|---|---|---|
|
||||||
|
| Distributeur / Courtier | Auto-référence Client (RG-1.03) | **Absent** |
|
||||||
|
| Prestation de triage | Booléen sur le client (formulaire principal) | **Booléen sur l'adresse** (`triage_provider`) |
|
||||||
|
| Type d'adresse | 3 checkboxes Prospect / Livraison / Facturation | **Radio exclusif** Prospect / Départ / Rendu (RG-2.09) |
|
||||||
|
| Email facturation sur adresse | Oui (conditionnel) | **Absent** |
|
||||||
|
| Champ adresse « Bennes » | — | **Présent** (nombre) |
|
||||||
|
| Onglet Information | 7 champs | **8 champs** (ajout « Volume prévisionnel ») |
|
||||||
|
| Catégories | type unique `CLIENT` (codes ERP-78) | **nouveau type `FOURNISSEUR`** |
|
||||||
|
| Archivage | Admin | **Admin uniquement** (idem) |
|
||||||
|
| Onglets « À venir » | frames blanches | **placeholder « À venir »** (minimal) |
|
||||||
|
|
||||||
|
## Points résolus côté back
|
||||||
|
|
||||||
|
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Catégorie multi-select | M2M `supplier_category`, `Category` de type **FOURNISSEUR** (RG-2.10) |
|
||||||
|
| 2 | Type d'adresse Prospect/Départ/Rendu | Enum exclusif `address_type` (RG-2.09) |
|
||||||
|
| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas |
|
||||||
|
| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
|
||||||
|
| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Transport / Stats / Rapports / Échanges) |
|
||||||
|
| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP |
|
||||||
|
| 7 | Unicité métier | Nom de fournisseur uniquement (à valider — § 2.6). SIREN/email non uniques |
|
||||||
|
| 8 | Référentiels comptables | Réutilisés du M1 (zéro duplication) |
|
||||||
|
| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1 |
|
||||||
|
| 10 | Format export | XLSX uniquement (CSV = HP) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Tickets Lesstime
|
||||||
|
|
||||||
|
**TaskGroup Lesstime** : à créer — `M2 — Répertoire fournisseurs` (projet `ERP / Starseed`, projectId=6).
|
||||||
|
|
||||||
|
> Détail complet et action manuelle → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
|
<!--
|
||||||
|
Valeurs en dur issues de la maquette Figma (design Starseed) :
|
||||||
|
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
|
||||||
|
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
|
||||||
|
- bande blanche sticky sous la navbar : 47px (h-[47px])
|
||||||
|
A faire evoluer uniquement avec une mise a jour de maquette.
|
||||||
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div class="h-screen overflow-hidden">
|
<div class="h-screen overflow-hidden">
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<MalioSidebar
|
<MalioSidebar
|
||||||
v-model="ui.sidebarCollapsed"
|
v-model="ui.sidebarCollapsed"
|
||||||
:sections="translatedSections"
|
:sections="translatedSections"
|
||||||
|
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||||
>
|
>
|
||||||
<template #logo>
|
<template #logo>
|
||||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||||
@@ -16,10 +24,10 @@
|
|||||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
<SiteSelector v-if="showSiteSelector"/>
|
<SiteSelector v-if="showSiteSelector"/>
|
||||||
<main
|
<main
|
||||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11">
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12"/>
|
class="pointer-events-none sticky top-0 z-30 h-11 flex-shrink-0 bg-white"/>
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
"confirm": "Confirmer",
|
"confirm": "Confirmer",
|
||||||
"yes": "Oui",
|
"yes": "Oui",
|
||||||
"no": "Non",
|
"no": "Non",
|
||||||
"actions": "Actions"
|
"actions": "Actions",
|
||||||
|
"comingSoon": {
|
||||||
|
"title": "En cours de dev",
|
||||||
|
"subtitle": "Cette fonctionnalité arrive bientôt."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"administration": {
|
"administration": {
|
||||||
@@ -23,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"commercial": {
|
"commercial": {
|
||||||
"section": "Commercial",
|
"section": "Commercial",
|
||||||
|
"clients": "Répertoire clients",
|
||||||
"suppliers": "Répertoire fournisseurs"
|
"suppliers": "Répertoire fournisseurs"
|
||||||
},
|
},
|
||||||
"core": {
|
"core": {
|
||||||
@@ -32,6 +37,9 @@
|
|||||||
},
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
"admin": "Sites"
|
"admin": "Sites"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"categories": "Gestion des catégories"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -40,7 +48,164 @@
|
|||||||
},
|
},
|
||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "Commercial",
|
"title": "Commercial",
|
||||||
"welcome": "Module Commercial"
|
"welcome": "Module Commercial",
|
||||||
|
"clients": {
|
||||||
|
"title": "Répertoire clients",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"export": "Exporter",
|
||||||
|
"empty": "Aucun client pour l'instant.",
|
||||||
|
"column": {
|
||||||
|
"companyName": "Nom",
|
||||||
|
"categories": "Catégories",
|
||||||
|
"sites": "Site",
|
||||||
|
"lastActivity": "Dernière activité"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"search": "Recherche",
|
||||||
|
"categories": "Catégories",
|
||||||
|
"sites": "Sites",
|
||||||
|
"status": "Statut",
|
||||||
|
"archivedOnly": "Voir les archivés",
|
||||||
|
"apply": "Voir les résultats",
|
||||||
|
"reset": "Réinitialiser"
|
||||||
|
},
|
||||||
|
"tab": {
|
||||||
|
"information": "Information",
|
||||||
|
"contact": "Contact",
|
||||||
|
"address": "Adresse",
|
||||||
|
"transport": "Transport",
|
||||||
|
"accounting": "Comptabilité",
|
||||||
|
"statistics": "Statistiques",
|
||||||
|
"reports": "Rapports",
|
||||||
|
"exchanges": "Échanges"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"edit": "Modifier",
|
||||||
|
"archive": "Archiver",
|
||||||
|
"restore": "Restaurer"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"createSuccess": "Client créé avec succès",
|
||||||
|
"updateSuccess": "Client mis à jour avec succès",
|
||||||
|
"archiveSuccess": "Client archivé avec succès",
|
||||||
|
"restoreSuccess": "Client restauré avec succès",
|
||||||
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
|
"exportError": "L'export du répertoire clients a échoué. Réessayez.",
|
||||||
|
"restoreConflict": "Impossible de restaurer : un client actif portant ce nom existe déjà."
|
||||||
|
},
|
||||||
|
"consultation": {
|
||||||
|
"title": "Consultation client",
|
||||||
|
"back": "Retour au répertoire",
|
||||||
|
"loading": "Chargement du client…",
|
||||||
|
"notFound": "Client introuvable.",
|
||||||
|
"confirmArchive": {
|
||||||
|
"title": "Archiver le client",
|
||||||
|
"message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
|
||||||
|
},
|
||||||
|
"confirmRestore": {
|
||||||
|
"title": "Restaurer le client",
|
||||||
|
"message": "Ce client réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Modifier le client",
|
||||||
|
"back": "Retour au répertoire",
|
||||||
|
"loading": "Chargement du client…",
|
||||||
|
"notFound": "Client introuvable.",
|
||||||
|
"save": "Valider"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
||||||
|
"contactRequired": "Au moins un contact (nom ou prénom) est obligatoire.",
|
||||||
|
"siteRequired": "Au moins un site Starseed doit être rattaché à l'adresse.",
|
||||||
|
"billingEmailRequired": "L'email de facturation est obligatoire pour une adresse de facturation.",
|
||||||
|
"bankRequiredForTransfer": "La banque est obligatoire pour un règlement par virement.",
|
||||||
|
"ribRequiredForLcr": "Au moins un RIB complet est obligatoire pour un règlement par LCR.",
|
||||||
|
"phoneFormat": "Format de téléphone invalide (attendu : XX XX XX XX XX).",
|
||||||
|
"emailFormat": "Format d'email invalide.",
|
||||||
|
"addressCategoryForbidden": "Une catégorie « Distributeur » ou « Courtier » ne peut pas qualifier une adresse."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "Ajouter un client",
|
||||||
|
"back": "Précédent",
|
||||||
|
"submit": "Valider",
|
||||||
|
"duplicateCompany": "Un client portant ce nom de société existe déjà.",
|
||||||
|
"main": {
|
||||||
|
"companyName": "Nom du client (Entreprise)",
|
||||||
|
"categories": "Catégorie",
|
||||||
|
"relation": "Distributeur / Courtier",
|
||||||
|
"relationNone": "Aucun",
|
||||||
|
"relationDistributor": "Dépend du distributeur",
|
||||||
|
"relationBroker": "Dépend du courtier",
|
||||||
|
"distributorName": "Nom du distributeur",
|
||||||
|
"brokerName": "Nom du courtier",
|
||||||
|
"triageService": "Prestation de triage"
|
||||||
|
},
|
||||||
|
"information": {
|
||||||
|
"description": "Description",
|
||||||
|
"competitors": "Concurrent",
|
||||||
|
"foundedAt": "Date de création",
|
||||||
|
"employeesCount": "Nombre de salariés",
|
||||||
|
"revenueAmount": "CA",
|
||||||
|
"profitAmount": "Résultat",
|
||||||
|
"directorName": "Dirigeant"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Contact {n}",
|
||||||
|
"lastName": "Nom",
|
||||||
|
"firstName": "Prénom",
|
||||||
|
"jobTitle": "Fonction",
|
||||||
|
"email": "Email",
|
||||||
|
"phonePrimary": "Téléphone",
|
||||||
|
"phoneSecondary": "Téléphone (2)",
|
||||||
|
"addPhone": "Ajouter un numéro",
|
||||||
|
"remove": "Supprimer le contact",
|
||||||
|
"add": "Nouveau contact"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"title": "Adresse {n}",
|
||||||
|
"prospect": "Prospect",
|
||||||
|
"delivery": "Adresse de livraison",
|
||||||
|
"billing": "Facturation",
|
||||||
|
"categories": "Catégorie",
|
||||||
|
"country": "Pays",
|
||||||
|
"postalCode": "Code postal",
|
||||||
|
"city": "Ville",
|
||||||
|
"street": "Adresse",
|
||||||
|
"streetComplement": "Adresse complémentaire",
|
||||||
|
"sites": "Sites Starseed",
|
||||||
|
"contacts": "Contact(s) rattaché(s)",
|
||||||
|
"billingEmail": "Email de facturation",
|
||||||
|
"remove": "Supprimer l'adresse",
|
||||||
|
"add": "Nouvelle adresse",
|
||||||
|
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||||
|
},
|
||||||
|
"accounting": {
|
||||||
|
"siren": "SIREN",
|
||||||
|
"accountNumber": "Numéro de compte",
|
||||||
|
"tvaMode": "Mode de TVA",
|
||||||
|
"nTva": "N° de TVA",
|
||||||
|
"paymentDelay": "Délai de règlement",
|
||||||
|
"paymentType": "Type de règlement",
|
||||||
|
"bank": "Banque",
|
||||||
|
"ribTitle": "RIB {n}",
|
||||||
|
"ribLabel": "Libellé",
|
||||||
|
"ribBic": "BIC",
|
||||||
|
"ribIban": "IBAN",
|
||||||
|
"addRib": "Ajouter un RIB",
|
||||||
|
"removeRib": "Supprimer le RIB"
|
||||||
|
},
|
||||||
|
"confirmDelete": {
|
||||||
|
"title": "Confirmer la suppression",
|
||||||
|
"contact": "Supprimer ce contact ?",
|
||||||
|
"address": "Supprimer cette adresse ?",
|
||||||
|
"rib": "Supprimer ce RIB ?",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirm": "Confirmer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Connexion",
|
"login": "Connexion",
|
||||||
@@ -81,16 +246,28 @@
|
|||||||
"core_user": "Utilisateur",
|
"core_user": "Utilisateur",
|
||||||
"core_role": "Rôle",
|
"core_role": "Rôle",
|
||||||
"core_permission": "Permission",
|
"core_permission": "Permission",
|
||||||
"sites_site": "Site"
|
"sites_site": "Site",
|
||||||
|
"catalog_category": "Catégorie",
|
||||||
|
"commercial_client": "Client",
|
||||||
|
"commercial_clientaddress": "Adresse client",
|
||||||
|
"commercial_clientcontact": "Contact client",
|
||||||
|
"commercial_clientrib": "RIB client"
|
||||||
},
|
},
|
||||||
"empty": "Aucune activité enregistrée",
|
"empty": "Aucune activité enregistrée",
|
||||||
"no_results": "Aucun résultat pour ces filtres",
|
"no_results": "Aucun résultat pour ces filtres",
|
||||||
|
"error": {
|
||||||
|
"title": "Erreur",
|
||||||
|
"message": "Impossible de charger le journal d'audit. Vérifiez les filtres ou réessayez."
|
||||||
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"empty": "Aucun historique",
|
"empty": "Aucun historique",
|
||||||
"load_more": "Voir plus"
|
"load_more": "Voir plus"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"apply": "Voir les résultats",
|
||||||
"reset": "Réinitialiser",
|
"reset": "Réinitialiser",
|
||||||
|
"date_range": "Date à date",
|
||||||
"date_from": "Du",
|
"date_from": "Du",
|
||||||
"date_to": "Au",
|
"date_to": "Au",
|
||||||
"entity_type": "Type d'entité",
|
"entity_type": "Type d'entité",
|
||||||
@@ -220,6 +397,39 @@
|
|||||||
"updated": "Site mis à jour avec succès",
|
"updated": "Site mis à jour avec succès",
|
||||||
"deleted": "Site supprimé avec succès"
|
"deleted": "Site supprimé avec succès"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"title": "Gestion des catégories",
|
||||||
|
"newCategory": "Ajouter",
|
||||||
|
"editCategory": "Modifier la catégorie",
|
||||||
|
"createCategory": "Créer une catégorie",
|
||||||
|
"viewCategory": "Détail de la catégorie",
|
||||||
|
"noCategories": "Aucune catégorie pour l'instant.",
|
||||||
|
"table": {
|
||||||
|
"name": "Nom",
|
||||||
|
"type": "Type"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Nom",
|
||||||
|
"type": "Type de catégorie",
|
||||||
|
"typePlaceholder": "Sélectionner un type"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Le nom est obligatoire.",
|
||||||
|
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
|
||||||
|
"typeRequired": "Le type de catégorie est obligatoire."
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Supprimer la catégorie",
|
||||||
|
"message": "Êtes-vous sûr de vouloir supprimer la catégorie \"{name}\" ? Cette action est irréversible."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"created": "Catégorie créée avec succès",
|
||||||
|
"updated": "Catégorie mise à jour avec succès",
|
||||||
|
"deleted": "Catégorie supprimée avec succès",
|
||||||
|
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
|
||||||
|
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<MalioModal
|
||||||
|
:model-value="modelValue"
|
||||||
|
modal-class="max-w-md"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold text-neutral-900">
|
||||||
|
{{ t('admin.categories.delete.title') }}
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p class="text-sm text-neutral-600">
|
||||||
|
{{ t('admin.categories.delete.message', { name: categoryName }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
variant="secondary"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('common.delete')"
|
||||||
|
variant="danger"
|
||||||
|
icon-name="mdi:delete-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="emit('confirm')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
categoryName: string
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
confirm: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<MalioDrawer
|
||||||
|
:model-value="modelValue"
|
||||||
|
drawer-class="w-full max-w-lg"
|
||||||
|
header-class="border-b border-black"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-2xl font-bold">
|
||||||
|
{{ headerLabel }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
||||||
|
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
||||||
|
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.name.value"
|
||||||
|
:label="t('admin.categories.form.name')"
|
||||||
|
input-class="w-full"
|
||||||
|
:max-length="120"
|
||||||
|
:error="form.errors.value.name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
|
||||||
|
number (categoryType id) ; conversion en IRI au moment du save
|
||||||
|
par le composable useCategoryForm. -->
|
||||||
|
<MalioSelect
|
||||||
|
v-model="form.categoryTypeId.value"
|
||||||
|
:options="typeOptions"
|
||||||
|
:label="t('admin.categories.form.type')"
|
||||||
|
:empty-option-label="t('admin.categories.form.typePlaceholder')"
|
||||||
|
: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
|
||||||
|
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canShowDelete"
|
||||||
|
:label="t('common.delete')"
|
||||||
|
variant="danger"
|
||||||
|
icon-name="mdi:delete-outline"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
|
@click="emit('delete')"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-else
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canShowSave"
|
||||||
|
:label="t('common.save')"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
|
:disabled="form.submitting.value || loadingTypes"
|
||||||
|
@click="handleSave"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Category } from '~/modules/catalog/types/category'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
const { types, loadingTypes, fetchTypes } = useCategoriesAdmin()
|
||||||
|
// Instance dediee de form pour ce drawer — state isole (cf. useCategoryForm
|
||||||
|
// n'est pas singleton, contrairement a useCategoriesAdmin).
|
||||||
|
const form = useCategoryForm()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
category: Category | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
saved: []
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mode du drawer (dérivé du composable `useCategoryForm`) :
|
||||||
|
* - 'create' : pas de category prop, formulaire vide, POST au save.
|
||||||
|
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
|
||||||
|
* jusqu'a ce que l'utilisateur modifie un champ.
|
||||||
|
* - 'edit' : category prop set et formulaire « dirty » (au moins un
|
||||||
|
* champ different de l'original), PATCH au save.
|
||||||
|
*/
|
||||||
|
type DrawerMode = 'create' | 'view' | 'edit'
|
||||||
|
|
||||||
|
const isCreateMode = computed(() => props.category === null)
|
||||||
|
|
||||||
|
const mode = computed<DrawerMode>(() => {
|
||||||
|
if (isCreateMode.value) return 'create'
|
||||||
|
return form.isDirty.value ? 'edit' : 'view'
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerLabel = computed(() => {
|
||||||
|
if (mode.value === 'create') return t('admin.categories.createCategory')
|
||||||
|
if (mode.value === 'edit') return t('admin.categories.editCategory')
|
||||||
|
return t('admin.categories.viewCategory')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
|
||||||
|
// existante et seulement pour les users ayant la permission manage. En mode
|
||||||
|
// creation on affiche un bouton Annuler a la place.
|
||||||
|
const canShowDelete = computed(
|
||||||
|
() => !isCreateMode.value && can('catalog.categories.manage'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save : visible en creation, ou en edition (apres modification d'un champ).
|
||||||
|
// Masque en view tant que rien n'a change.
|
||||||
|
const canShowSave = computed(
|
||||||
|
() => mode.value === 'create' || mode.value === 'edit',
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeOptions = computed(() =>
|
||||||
|
types.value.map(ct => ({
|
||||||
|
label: ct.label,
|
||||||
|
value: ct.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Re-initialise le form quand la categorie selectionnee change (clic sur une
|
||||||
|
// autre ligne sans fermer le drawer entre-temps).
|
||||||
|
watch(() => props.category, (cat) => {
|
||||||
|
form.loadFrom(cat)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// A chaque ouverture du drawer : reload du form + refresh des types (au cas
|
||||||
|
// ou un type aurait ete ajoute en arriere-plan depuis le dernier fetch — pas
|
||||||
|
// d'optimisation cache au M0, le referentiel est petit).
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
form.loadFrom(props.category)
|
||||||
|
fetchTypes()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
|
||||||
|
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
|
||||||
|
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il
|
||||||
|
* refresh la liste.
|
||||||
|
*/
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
let result: Category | null = null
|
||||||
|
if (mode.value === 'create') {
|
||||||
|
result = await form.submitCreate()
|
||||||
|
} else if (mode.value === 'edit' && props.category) {
|
||||||
|
result = await form.submitUpdate(props.category.id)
|
||||||
|
}
|
||||||
|
if (result) {
|
||||||
|
emit('saved')
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import type { CategoryType } from '~/modules/catalog/types/category'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
||||||
|
// `onAuthSessionCleared(...)` au chargement du module. On stubbe pour
|
||||||
|
// eviter de charger Pinia et la vraie store (pas necessaire ici).
|
||||||
|
vi.mock('~/shared/stores/auth', () => ({
|
||||||
|
onAuthSessionCleared: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Le client API est un auto-import Nuxt. On le remplace par un stub
|
||||||
|
// global pour intercepter les appels et controler les reponses dans
|
||||||
|
// chaque test (cf. pattern utilise dans useCurrentSite.spec.ts).
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import APRES vi.mock / vi.stubGlobal : le module n'est evalue qu'a
|
||||||
|
// ce moment-la, donc le mock auth est bien actif au top-level.
|
||||||
|
const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
|
||||||
|
|
||||||
|
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||||
|
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||||
|
|
||||||
|
function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||||
|
return {
|
||||||
|
totalItems: items.length,
|
||||||
|
member: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apres ERP-73, `useCategoriesAdmin` ne porte plus la liste paginee des
|
||||||
|
* categories (elle est geree par `usePaginatedList<Category>` cote page).
|
||||||
|
* Le composable se concentre sur le referentiel CategoryType (lecture
|
||||||
|
* seule, ≤ 5 entrees connues) charge en une fois via `?pagination=false`.
|
||||||
|
*/
|
||||||
|
describe('useCategoriesAdmin', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
// Reset systematique du state singleton entre tests : sans ca,
|
||||||
|
// les types charges dans un test fuiteraient dans le suivant.
|
||||||
|
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||||
|
resetCategoriesAdmin()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchTypes', () => {
|
||||||
|
it('appelle GET /category_types avec ?pagination=false (echappatoire selects)', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
||||||
|
const { fetchTypes } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
await fetchTypes()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(
|
||||||
|
'/category_types',
|
||||||
|
{ pagination: 'false' },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('peuple types.value depuis le champ Hydra member', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce(makeHydra([TYPE_VENTE, TYPE_ACHAT]))
|
||||||
|
const { fetchTypes, types } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
await fetchTypes()
|
||||||
|
|
||||||
|
expect(types.value).toEqual([TYPE_VENTE, TYPE_ACHAT])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('peuple error.value et vide types en cas d echec', async () => {
|
||||||
|
mockGet.mockRejectedValueOnce(new Error('500'))
|
||||||
|
const { fetchTypes, types, error, loadingTypes } = useCategoriesAdmin()
|
||||||
|
types.value = [TYPE_VENTE]
|
||||||
|
|
||||||
|
await fetchTypes()
|
||||||
|
|
||||||
|
expect(types.value).toEqual([])
|
||||||
|
expect(error.value).toContain('500')
|
||||||
|
expect(loadingTypes.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe loadingTypes a true pendant la requete et false apres', async () => {
|
||||||
|
let resolveRequest: (v: HydraCollection<CategoryType>) => void = () => {}
|
||||||
|
mockGet.mockImplementationOnce(
|
||||||
|
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||||
|
)
|
||||||
|
const { fetchTypes, loadingTypes } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
const pending = fetchTypes()
|
||||||
|
expect(loadingTypes.value).toBe(true)
|
||||||
|
|
||||||
|
resolveRequest(makeHydra<CategoryType>([]))
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(loadingTypes.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
|
||||||
|
mockGet.mockResolvedValueOnce({
|
||||||
|
totalItems: 0,
|
||||||
|
} as unknown as HydraCollection<CategoryType>)
|
||||||
|
const { fetchTypes, types } = useCategoriesAdmin()
|
||||||
|
|
||||||
|
await fetchTypes()
|
||||||
|
|
||||||
|
expect(types.value).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetCategoriesAdmin', () => {
|
||||||
|
it('vide types, loadingTypes et error', () => {
|
||||||
|
const { resetCategoriesAdmin, types, loadingTypes, error }
|
||||||
|
= useCategoriesAdmin()
|
||||||
|
// Pre-peuple le state pour verifier la purge effective.
|
||||||
|
types.value = [TYPE_VENTE]
|
||||||
|
loadingTypes.value = true
|
||||||
|
error.value = 'oops'
|
||||||
|
|
||||||
|
resetCategoriesAdmin()
|
||||||
|
|
||||||
|
expect(types.value).toEqual([])
|
||||||
|
expect(loadingTypes.value).toBe(false)
|
||||||
|
expect(error.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('singleton', () => {
|
||||||
|
it('deux appels a useCategoriesAdmin() partagent la meme ref types', () => {
|
||||||
|
const a = useCategoriesAdmin()
|
||||||
|
const b = useCategoriesAdmin()
|
||||||
|
|
||||||
|
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
||||||
|
// doivent etre rigoureusement les memes (state au niveau module).
|
||||||
|
expect(a.types).toBe(b.types)
|
||||||
|
expect(a.loadingTypes).toBe(b.loadingTypes)
|
||||||
|
expect(a.error).toBe(b.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('une mutation via une instance est visible depuis une autre instance', () => {
|
||||||
|
const a = useCategoriesAdmin()
|
||||||
|
const b = useCategoriesAdmin()
|
||||||
|
|
||||||
|
a.types.value = [TYPE_VENTE]
|
||||||
|
|
||||||
|
expect(b.types.value).toEqual([TYPE_VENTE])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||||
|
import { useCategoryForm } from '../useCategoryForm'
|
||||||
|
|
||||||
|
// Stubs des auto-imports Nuxt consommes par le composable.
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockDelete = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastError = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: mockPatch,
|
||||||
|
delete: mockDelete,
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('useToast', () => ({
|
||||||
|
success: mockToastSuccess,
|
||||||
|
error: mockToastError,
|
||||||
|
}))
|
||||||
|
// 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.
|
||||||
|
vi.stubGlobal('useI18n', () => ({
|
||||||
|
t: (key: string, params?: Record<string, unknown>) =>
|
||||||
|
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||||
|
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||||
|
|
||||||
|
const CAT: Category = {
|
||||||
|
id: 42,
|
||||||
|
name: 'Vis',
|
||||||
|
categoryType: TYPE_VENTE,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: '2026-01-01T10:00:00+00:00',
|
||||||
|
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||||
|
createdBy: null,
|
||||||
|
updatedBy: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useCategoryForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
mockDelete.mockReset()
|
||||||
|
mockToastSuccess.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadFrom', () => {
|
||||||
|
it('pre-remplit le formulaire depuis une categorie existante', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
|
||||||
|
expect(form.name.value).toBe('Vis')
|
||||||
|
expect(form.categoryTypeId.value).toBe(1)
|
||||||
|
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vide le formulaire en mode creation (null)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'old'
|
||||||
|
form.categoryTypeId.value = 99
|
||||||
|
|
||||||
|
form.loadFrom(null)
|
||||||
|
|
||||||
|
expect(form.name.value).toBe('')
|
||||||
|
expect(form.categoryTypeId.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
|
||||||
|
expect(form.isDirty.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isDirty', () => {
|
||||||
|
it('passe a true des qu une valeur diverge du snapshot initial', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
expect(form.isDirty.value).toBe(false)
|
||||||
|
|
||||||
|
form.name.value = 'Vis modifie'
|
||||||
|
|
||||||
|
expect(form.isDirty.value).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validate', () => {
|
||||||
|
it('signale une erreur si name est vide (RG-1.02)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = ''
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale erreur si name est whitespace-only (trim → vide)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = ' '
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'A'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'A'.repeat(121)
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = null
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe quand name et categoryType sont valides', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const ok = form.validate()
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reinitialise les erreurs avant chaque validation', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
// Erreur prealable.
|
||||||
|
form.errors.value._global = 'erreur ancienne'
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
form.validate()
|
||||||
|
|
||||||
|
expect(form.errors.value._global).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submitCreate', () => {
|
||||||
|
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce(CAT)
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = ' Vis '
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/categories',
|
||||||
|
{ name: 'Vis', categoryType: '/api/category_types/1' },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
expect(result).toEqual(CAT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne declenche aucun appel API si la validation client echoue', async () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = ''
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('declenche un toast de succes en cas de creation reussie', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce(CAT)
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
await form.submitCreate()
|
||||||
|
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||||
|
title: 'Succès',
|
||||||
|
message: 'admin.categories.toast.created',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe un 409 (RG-1.07) sur errors.name + toast erreur avec le nom', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: { status: 409, _data: {} },
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
|
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.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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe un 422 violations sur les champs concernes (errors.name)', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: {
|
||||||
|
violations: [
|
||||||
|
{ propertyPath: 'name', message: 'name should not be blank.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const result = await form.submitCreate()
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: {
|
||||||
|
'hydra:violations': [
|
||||||
|
{ propertyPath: 'categoryType', message: 'Type invalide.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
await form.submitCreate()
|
||||||
|
|
||||||
|
expect(form.errors.value.categoryType).toBe('Type invalide.')
|
||||||
|
})
|
||||||
|
|
||||||
|
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' } },
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
await form.submitCreate()
|
||||||
|
|
||||||
|
expect(form.errors.value._global).toBe('Boom server')
|
||||||
|
expect(mockToastError).toHaveBeenCalledWith({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Boom server',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passe submitting a true pendant la requete et a false apres', async () => {
|
||||||
|
let resolveRequest: (v: Category) => void = () => {}
|
||||||
|
mockPost.mockImplementationOnce(
|
||||||
|
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||||
|
)
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.name.value = 'Vis'
|
||||||
|
form.categoryTypeId.value = 1
|
||||||
|
|
||||||
|
const pending = form.submitCreate()
|
||||||
|
expect(form.submitting.value).toBe(true)
|
||||||
|
|
||||||
|
resolveRequest(CAT)
|
||||||
|
await pending
|
||||||
|
|
||||||
|
expect(form.submitting.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submitUpdate', () => {
|
||||||
|
it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
form.name.value = 'Vis V2' // categoryTypeId inchange
|
||||||
|
|
||||||
|
await form.submitUpdate(42)
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/categories/42',
|
||||||
|
{ name: 'Vis V2' }, // pas de categoryType car non modifie
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('envoie categoryType en IRI quand seul le type a change', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
form.categoryTypeId.value = 2
|
||||||
|
|
||||||
|
await form.submitUpdate(42)
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/categories/42',
|
||||||
|
{ categoryType: '/api/category_types/2' },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('court-circuite l appel API si aucun champ n a change', async () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
// Aucune modification — isDirty=false, patch payload vide.
|
||||||
|
|
||||||
|
const result = await form.submitUpdate(42)
|
||||||
|
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(form.submitting.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('declenche un toast de succes au PATCH reussi', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
form.name.value = 'Vis V2'
|
||||||
|
|
||||||
|
await form.submitUpdate(42)
|
||||||
|
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||||
|
title: 'Succès',
|
||||||
|
message: 'admin.categories.toast.updated',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe le 409 sur errors.name en mode update aussi', async () => {
|
||||||
|
mockPatch.mockRejectedValueOnce({
|
||||||
|
response: { status: 409, _data: {} },
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
form.name.value = 'Doublon'
|
||||||
|
|
||||||
|
const result = await form.submitUpdate(42)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||||
|
expect(form.errors.value.name).toContain('"name":"Doublon"')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submitDelete', () => {
|
||||||
|
it('appelle DELETE /categories/{id} et declenche un toast succes', async () => {
|
||||||
|
mockDelete.mockResolvedValueOnce(undefined)
|
||||||
|
const form = useCategoryForm()
|
||||||
|
|
||||||
|
const ok = await form.submitDelete(42)
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||||
|
title: 'Succès',
|
||||||
|
message: 'admin.categories.toast.deleted',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne false et toast erreur en cas d echec', async () => {
|
||||||
|
mockDelete.mockRejectedValueOnce({
|
||||||
|
response: { status: 500, _data: { detail: 'down' } },
|
||||||
|
})
|
||||||
|
const form = useCategoryForm()
|
||||||
|
|
||||||
|
const ok = await form.submitDelete(42)
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.errors.value._global).toBe('down')
|
||||||
|
expect(mockToastError).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('vide le formulaire et les erreurs', () => {
|
||||||
|
const form = useCategoryForm()
|
||||||
|
form.loadFrom(CAT)
|
||||||
|
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.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||||
|
expect(form.submitting.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isolation', () => {
|
||||||
|
it('deux instances useCategoryForm() ont des states independants', () => {
|
||||||
|
const a = useCategoryForm()
|
||||||
|
const b = useCategoryForm()
|
||||||
|
|
||||||
|
a.name.value = 'A'
|
||||||
|
b.name.value = 'B'
|
||||||
|
|
||||||
|
expect(a.name.value).toBe('A')
|
||||||
|
expect(b.name.value).toBe('B')
|
||||||
|
// Les refs sont distinctes (pas singleton — chaque drawer son state).
|
||||||
|
expect(a.name).not.toBe(b.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Composable de chargement du referentiel CategoryType (M0 — Gestion des
|
||||||
|
* categories).
|
||||||
|
*
|
||||||
|
* Apres ERP-73 (composable de liste paginee), la liste des categories
|
||||||
|
* elle-meme passe par `usePaginatedList<Category>` directement dans
|
||||||
|
* `admin/categories.vue` — c'est un etat propre a la page (pagination,
|
||||||
|
* filtres, tri locaux). Ce composable se concentre donc sur le
|
||||||
|
* referentiel CategoryType : petite collection lue une fois et reutilisee
|
||||||
|
* dans le drawer (select de type) → singleton volontaire pour eviter de
|
||||||
|
* la recharger a chaque ouverture du drawer.
|
||||||
|
*
|
||||||
|
* State singleton au niveau module : reset automatique au logout via
|
||||||
|
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
|
||||||
|
* explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { CategoryType } from '~/modules/catalog/types/category'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CategoryType est un referentiel lecture-seule (RG-1.06) avec une
|
||||||
|
* cardinalite minuscule (≤ 5 entrees connues). On force `pagination=false`
|
||||||
|
* pour recuperer toutes les entrees en un appel et alimenter le select du
|
||||||
|
* drawer sans pagination — echappatoire prevue par
|
||||||
|
* `pagination_client_enabled: true` cote API Platform.
|
||||||
|
*/
|
||||||
|
const NO_PAGINATION_QUERY = { pagination: 'false' } as const
|
||||||
|
|
||||||
|
const types = ref<CategoryType[]>([])
|
||||||
|
const loadingTypes = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
function resetCategoriesAdminState(): void {
|
||||||
|
types.value = []
|
||||||
|
loadingTypes.value = false
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-enregistrement singleton : purge le state sur 401/clearSession
|
||||||
|
// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
|
||||||
|
// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
|
||||||
|
// appelle directement `resetCategoriesAdmin()` ci-dessous.
|
||||||
|
onAuthSessionCleared(resetCategoriesAdminState)
|
||||||
|
|
||||||
|
export function useCategoriesAdmin() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le referentiel CategoryType. Appele a l'ouverture de la page
|
||||||
|
* admin pour que le select du drawer ait deja les options pretes au
|
||||||
|
* moment de la creation/edition.
|
||||||
|
*
|
||||||
|
* Toast desactive : on stocke l'erreur dans `error` plutot que de
|
||||||
|
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
||||||
|
*/
|
||||||
|
async function fetchTypes(): Promise<void> {
|
||||||
|
loadingTypes.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<HydraCollection<CategoryType>>(
|
||||||
|
'/category_types',
|
||||||
|
NO_PAGINATION_QUERY,
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
types.value = data.member ?? []
|
||||||
|
} catch (e) {
|
||||||
|
types.value = []
|
||||||
|
error.value = (e as Error)?.message ?? 'Erreur de chargement des types'
|
||||||
|
} finally {
|
||||||
|
loadingTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
|
||||||
|
* pour garantir que la prochaine session reparte sur un state propre
|
||||||
|
* meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
||||||
|
*/
|
||||||
|
function resetCategoriesAdmin(): void {
|
||||||
|
resetCategoriesAdminState()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
types,
|
||||||
|
loadingTypes,
|
||||||
|
error,
|
||||||
|
fetchTypes,
|
||||||
|
resetCategoriesAdmin,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* Composable de formulaire categorie (M0 — Gestion des categories).
|
||||||
|
*
|
||||||
|
* Centralise la logique de validation client + appels API (POST / PATCH /
|
||||||
|
* DELETE) du drawer de creation/edition. Contrairement a
|
||||||
|
* `useCategoriesAdmin` qui porte un state singleton partage entre composants,
|
||||||
|
* ce composable est instancie par formulaire (les refs vivent dans la
|
||||||
|
* fonction `useCategoryForm()`) — chaque drawer ouvert a son propre state
|
||||||
|
* isole.
|
||||||
|
*
|
||||||
|
* Validations client en miroir des regles back (RG-1.02 / RG-1.04 / RG-1.05) :
|
||||||
|
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
|
||||||
|
* revalide toujours (defense en profondeur).
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* (status et payload data) pour eviter de typer toute la lib.
|
||||||
|
*/
|
||||||
|
interface ApiFetchError {
|
||||||
|
response?: {
|
||||||
|
status?: number
|
||||||
|
_data?: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategoryForm() {
|
||||||
|
const api = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// 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('')
|
||||||
|
const categoryTypeId = ref<number | null>(null)
|
||||||
|
|
||||||
|
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
|
||||||
|
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
|
||||||
|
// que rien n'a change en mode consultation).
|
||||||
|
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(
|
||||||
|
() =>
|
||||||
|
name.value !== initialName.value
|
||||||
|
|| categoryTypeId.value !== initialCategoryTypeId.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-remplit le formulaire a partir d'une categorie existante (mode
|
||||||
|
* consultation/edition) ou vide (mode creation). Reinitialise les
|
||||||
|
* erreurs et le snapshot initial pour repartir d'un etat propre.
|
||||||
|
*/
|
||||||
|
function loadFrom(category: Category | null): void {
|
||||||
|
errors.value = { name: '', categoryType: '', _global: '' }
|
||||||
|
if (category) {
|
||||||
|
name.value = category.name
|
||||||
|
categoryTypeId.value = category.categoryType.id
|
||||||
|
initialName.value = category.name
|
||||||
|
initialCategoryTypeId.value = category.categoryType.id
|
||||||
|
} else {
|
||||||
|
name.value = ''
|
||||||
|
categoryTypeId.value = null
|
||||||
|
initialName.value = ''
|
||||||
|
initialCategoryTypeId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation client miroir des RG back. Renvoie true si tout passe et
|
||||||
|
* peuple `errors` sinon. Le trim est applique cote client (miroir RG-1.03)
|
||||||
|
* mais le serveur retrim de toute facon — pas de risque de divergence.
|
||||||
|
*/
|
||||||
|
function validate(): boolean {
|
||||||
|
errors.value = { name: '', categoryType: '', _global: '' }
|
||||||
|
const trimmedName = name.value.trim()
|
||||||
|
|
||||||
|
// RG-1.02 — name obligatoire (vide / whitespace-only).
|
||||||
|
if (trimmedName === '') {
|
||||||
|
errors.value.name = t('admin.categories.validation.nameRequired')
|
||||||
|
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
|
||||||
|
// RG-1.04 — longueur 2-120 apres trim.
|
||||||
|
errors.value.name = t('admin.categories.validation.nameLength')
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-1.05 — categoryType obligatoire.
|
||||||
|
if (categoryTypeId.value === null) {
|
||||||
|
errors.value.categoryType = t('admin.categories.validation.typeRequired')
|
||||||
|
}
|
||||||
|
|
||||||
|
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. 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 {
|
||||||
|
name: name.value.trim(),
|
||||||
|
categoryType: `/api/category_types/${categoryTypeId.value}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
})
|
||||||
|
errors.value.name = duplicateMessage
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: duplicateMessage,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/categories. Renvoie la categorie creee, ou `null` si la
|
||||||
|
* validation client a echoue ou si le serveur a renvoye une erreur. Le
|
||||||
|
* caller (drawer) decide quoi faire en fonction (fermer ou rester ouvert).
|
||||||
|
*/
|
||||||
|
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: 'Succès',
|
||||||
|
message: t('admin.categories.toast.created'),
|
||||||
|
})
|
||||||
|
return created
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e, String(payload.name))
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
|
||||||
|
* coller a la semantique merge-patch (Content-Type pose par useApi).
|
||||||
|
* Renvoie la categorie mise a jour, ou `null` en cas d'echec.
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
if (categoryTypeId.value !== initialCategoryTypeId.value) {
|
||||||
|
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
|
||||||
|
}
|
||||||
|
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
|
||||||
|
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
|
||||||
|
// on protege le composable contre un appel direct mal utilise.
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
submitting.value = false
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<Category>(`/categories/${id}`, payload, {
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès',
|
||||||
|
message: t('admin.categories.toast.updated'),
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
} catch (e) {
|
||||||
|
const attemptedName = typeof payload.name === 'string'
|
||||||
|
? payload.name
|
||||||
|
: name.value.trim()
|
||||||
|
handleApiError(e, attemptedName)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose
|
||||||
|
* `deleted_at = now()` et retourne 204. Renvoie true en cas de succes,
|
||||||
|
* false sinon (avec toast erreur deja affiche).
|
||||||
|
*/
|
||||||
|
async function submitDelete(id: number): Promise<boolean> {
|
||||||
|
submitting.value = true
|
||||||
|
errors.value._global = ''
|
||||||
|
try {
|
||||||
|
await api.delete(`/categories/${id}`, {}, { toast: false })
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès',
|
||||||
|
message: t('admin.categories.toast.deleted'),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e, name.value)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset complet du formulaire — utilise par le drawer apres save ou
|
||||||
|
* fermeture pour ne pas garder de donnees stale entre deux ouvertures.
|
||||||
|
*/
|
||||||
|
function reset(): void {
|
||||||
|
name.value = ''
|
||||||
|
categoryTypeId.value = null
|
||||||
|
initialName.value = ''
|
||||||
|
initialCategoryTypeId.value = null
|
||||||
|
errors.value = { name: '', categoryType: '', _global: '' }
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
name,
|
||||||
|
categoryTypeId,
|
||||||
|
errors,
|
||||||
|
submitting,
|
||||||
|
isDirty,
|
||||||
|
// Methods
|
||||||
|
loadFrom,
|
||||||
|
validate,
|
||||||
|
submitCreate,
|
||||||
|
submitUpdate,
|
||||||
|
submitDelete,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default defineNuxtConfig({})
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>
|
||||||
|
{{ t('admin.categories.title') }}
|
||||||
|
<template #actions>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManage"
|
||||||
|
:label="t('admin.categories.newCategory')"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
@click="openCreateDrawer"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Table des categories. Tri serveur (name ASC, RG-1.10) +
|
||||||
|
pagination serveur via usePaginatedList (#73). Le composable
|
||||||
|
remplace l'ancien chargement « tout en un coup » a volumetrie
|
||||||
|
cible ≤ 300 — la pagination est desormais alignee sur la regle
|
||||||
|
projet (toute collection paginee, regle ABSOLUE n°13). -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="categoryItems"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
:row-clickable="true"
|
||||||
|
:empty-message="t('admin.categories.noCategories')"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Drawer creation / consultation / edition. -->
|
||||||
|
<CategoryDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:category="selectedCategory"
|
||||||
|
@saved="onCategorySaved"
|
||||||
|
@delete="onDeleteRequest"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modale de confirmation suppression (soft delete cote serveur). -->
|
||||||
|
<CategoryDeleteModal
|
||||||
|
v-model="deleteModalOpen"
|
||||||
|
:category-name="categoryToDelete?.name ?? ''"
|
||||||
|
:loading="deleting"
|
||||||
|
@confirm="handleDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Category } from '~/modules/catalog/types/category'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
const { fetchTypes } = useCategoriesAdmin()
|
||||||
|
const { submitDelete } = useCategoryForm()
|
||||||
|
|
||||||
|
useHead({ title: t('admin.categories.title') })
|
||||||
|
|
||||||
|
const canManage = computed(() => can('catalog.categories.manage'))
|
||||||
|
|
||||||
|
// Pagination serveur via le composable partage (#73). Le CategoryProvider
|
||||||
|
// applique deja name ASC (RG-1.10) — pas besoin de defaultSort cote front
|
||||||
|
// tant qu'aucun OrderFilter n'est expose.
|
||||||
|
const {
|
||||||
|
items: categories,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: fetchCategories,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = usePaginatedList<Category>({ url: '/categories' })
|
||||||
|
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedCategory = ref<Category | null>(null)
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const categoryToDelete = ref<Category | null>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
|
||||||
|
// on aplatit en label lisible pour l'affichage.
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: t('admin.categories.table.name') },
|
||||||
|
{ key: 'typeLabel', label: t('admin.categories.table.type') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const categoryItems = computed(() =>
|
||||||
|
categories.value.map(cat => ({
|
||||||
|
id: cat.id,
|
||||||
|
name: cat.name,
|
||||||
|
typeLabel: cat.categoryType?.label ?? '',
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function getCategoryById(id: number): Category | undefined {
|
||||||
|
return categories.value.find(c => c.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClick(item: Record<string, unknown>) {
|
||||||
|
const category = getCategoryById(item.id as number)
|
||||||
|
if (category) openEditDrawer(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDrawer() {
|
||||||
|
selectedCategory.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDrawer(category: Category) {
|
||||||
|
selectedCategory.value = category
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteRequest() {
|
||||||
|
if (!selectedCategory.value) return
|
||||||
|
categoryToDelete.value = selectedCategory.value
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete via le composable de form (qui gere toast + erreur). Refresh
|
||||||
|
* de la liste a la fin pour retirer la ligne. L'index unique partiel
|
||||||
|
* autorise une recreation ulterieure avec le meme couple (name, type) —
|
||||||
|
* RG-1.07.
|
||||||
|
*/
|
||||||
|
async function handleDelete(): Promise<void> {
|
||||||
|
if (!categoryToDelete.value) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
const ok = await submitDelete(categoryToDelete.value.id)
|
||||||
|
if (ok) {
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
categoryToDelete.value = null
|
||||||
|
drawerOpen.value = false
|
||||||
|
await fetchCategories()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCategorySaved() {
|
||||||
|
fetchCategories()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chargement initial des deux ressources (liste + referentiel des types).
|
||||||
|
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
|
||||||
|
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCategories()
|
||||||
|
fetchTypes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Types front du module Catalog (M0 — Gestion des categories).
|
||||||
|
*
|
||||||
|
* Contrats API consommes :
|
||||||
|
* - GET /api/categories → HydraCollection<Category>
|
||||||
|
* - GET /api/categories/{id} → Category
|
||||||
|
* - POST /api/categories → body { name, categoryType: IRI }
|
||||||
|
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
|
||||||
|
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
|
||||||
|
* - GET /api/category_types → HydraCollection<CategoryType>
|
||||||
|
*
|
||||||
|
* Notes :
|
||||||
|
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
|
||||||
|
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
|
||||||
|
* proprietes de CategoryType, cf. spec-back § 3.4).
|
||||||
|
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
|
||||||
|
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference legere d'un user, telle qu'embarquee dans Category.createdBy /
|
||||||
|
* updatedBy. Volontairement minimaliste : on n'a besoin que de l'identifiant
|
||||||
|
* et de l'username pour l'affichage courant.
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference du referentiel CategoryType (lecture seule au M0).
|
||||||
|
*/
|
||||||
|
export interface CategoryType {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorie metier — telle qu'elle est lue depuis l'API. L'entite porte le
|
||||||
|
* pattern Timestampable+Blamable (cf. spec-back § 2.8).
|
||||||
|
*/
|
||||||
|
export interface Category {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
categoryType: CategoryType
|
||||||
|
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
|
||||||
|
deletedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
createdBy: User | null
|
||||||
|
updatedBy: User | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepte en POST /api/categories. `categoryType` est envoye en
|
||||||
|
* IRI Hydra (ex. `/api/category_types/3`).
|
||||||
|
*/
|
||||||
|
export interface CategoryCreateInput {
|
||||||
|
name: string
|
||||||
|
categoryType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepte en PATCH /api/categories/{id}. Tous les champs sont
|
||||||
|
* optionnels (modification partielle).
|
||||||
|
*/
|
||||||
|
export interface CategoryUpdateInput {
|
||||||
|
name?: string
|
||||||
|
categoryType?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative 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)]">
|
||||||
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="removable && !readonly"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Usage de l'adresse : Prospect exclusif de Livraison/Facturation
|
||||||
|
(RG-1.06/07/08). L'exclusivite est appliquee au toggle (cocher l'un
|
||||||
|
decoche l'autre) plutot qu'en masquant les options. -->
|
||||||
|
<MalioCheckbox
|
||||||
|
:model-value="model.isProspect"
|
||||||
|
:label="t('commercial.clients.form.address.prospect')"
|
||||||
|
group-class="self-center"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
:model-value="model.isDelivery"
|
||||||
|
:label="t('commercial.clients.form.address.delivery')"
|
||||||
|
group-class="self-center"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
:model-value="model.isBilling"
|
||||||
|
:label="t('commercial.clients.form.address.billing')"
|
||||||
|
group-class="self-center"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cellule vide : laisse un trou en position 4 (ligne 1) pour que
|
||||||
|
Categorie reparte au debut de la ligne suivante. -->
|
||||||
|
<div aria-hidden="true" />
|
||||||
|
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.categoryIris"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:label="t('commercial.clients.form.address.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:disabled="readonly"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.country"
|
||||||
|
:options="countryOptions"
|
||||||
|
:label="t('commercial.clients.form.address.country')"
|
||||||
|
:disabled="readonly"
|
||||||
|
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.postalCode"
|
||||||
|
:label="t('commercial.clients.form.address.postalCode')"
|
||||||
|
:mask="POSTAL_CODE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="onPostalCodeChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Ville : MalioSelect alimente par le code postal (BAN). En mode
|
||||||
|
degrade (service indisponible), bascule en saisie libre. -->
|
||||||
|
<MalioSelect
|
||||||
|
v-if="!degraded"
|
||||||
|
:model-value="model.city"
|
||||||
|
:options="cityOptions"
|
||||||
|
:label="t('commercial.clients.form.address.city')"
|
||||||
|
:disabled="readonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else
|
||||||
|
:model-value="model.city"
|
||||||
|
:label="t('commercial.clients.form.address.city')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('city', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
|
||||||
|
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
|
||||||
|
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
|
||||||
|
le col-span-2, le champ le remplit (w-full). -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple en
|
||||||
|
mode degrade OU en lecture seule (MalioInputAutocomplete ne reaffiche
|
||||||
|
pas sa valeur liee, il n'afficherait rien en readonly). -->
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-if="!degraded && !readonly"
|
||||||
|
:model-value="model.street"
|
||||||
|
:options="addressOptions"
|
||||||
|
:loading="addressLoading"
|
||||||
|
:min-search-length="3"
|
||||||
|
:label="t('commercial.clients.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||||
|
@search="onAddressSearch"
|
||||||
|
@select="onAddressSelect"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else
|
||||||
|
:model-value="model.street"
|
||||||
|
:label="t('commercial.clients.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('street', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-2">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.streetComplement"
|
||||||
|
:label="t('commercial.clients.form.address.streetComplement')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sites Starseed : cases a cocher inline (>= 1 obligatoire, RG-1.10). -->
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="site in siteOptions"
|
||||||
|
:key="site.value"
|
||||||
|
:model-value="model.siteIris.includes(site.value)"
|
||||||
|
:label="site.label"
|
||||||
|
group-class="w-auto self-center"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: boolean) => toggleSite(site.value, v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.contactIris"
|
||||||
|
:options="contactOptions"
|
||||||
|
:label="t('commercial.clients.form.address.contacts')"
|
||||||
|
:display-tag="true"
|
||||||
|
:disabled="readonly"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Email de facturation : visible/obligatoire seulement si Facturation
|
||||||
|
est coche (RG-1.11). -->
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isBillingEmailRequired(model)"
|
||||||
|
:model-value="model.billingEmail"
|
||||||
|
:label="t('commercial.clients.form.address.billingEmail')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
applyProspectExclusivity,
|
||||||
|
isBillingEmailRequired,
|
||||||
|
type AddressFlagsDraft,
|
||||||
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||||
|
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
|
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
|
// Masque code postal FR : 5 chiffres.
|
||||||
|
const POSTAL_CODE_MASK = '#####'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Brouillon de l'adresse (v-model). */
|
||||||
|
modelValue: AddressFormDraft
|
||||||
|
title: string
|
||||||
|
/** Categories autorisees sur une adresse (DISTRIBUTEUR/COURTIER exclus, RG-1.29). */
|
||||||
|
categoryOptions: CategoryOption[]
|
||||||
|
/** Sites Starseed disponibles. */
|
||||||
|
siteOptions: RefOption[]
|
||||||
|
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||||
|
contactOptions: RefOption[]
|
||||||
|
/** Pays disponibles (France par defaut). */
|
||||||
|
countryOptions: RefOption[]
|
||||||
|
removable?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: AddressFormDraft]
|
||||||
|
'remove': []
|
||||||
|
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||||
|
'degraded': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const autocomplete = useAddressAutocomplete()
|
||||||
|
|
||||||
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
|
||||||
|
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[]>([])
|
||||||
|
|
||||||
|
// Options ville effectives : on garantit que la ville courante figure toujours
|
||||||
|
// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options)
|
||||||
|
// afficherait un champ vide en lecture seule (consultation 1.11) ou en edition
|
||||||
|
// d'une adresse existante (1.12), ou la BAN n'a pas (re)peuple les suggestions.
|
||||||
|
const cityOptions = computed<RefOption[]>(() => {
|
||||||
|
const current = props.modelValue.city
|
||||||
|
if (current && !banCityOptions.value.some(o => o.value === current)) {
|
||||||
|
return [{ value: current, label: current }, ...banCityOptions.value]
|
||||||
|
}
|
||||||
|
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[] = []
|
||||||
|
|
||||||
|
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||||
|
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coche/decoche un site Starseed rattache a l'adresse (M2M par IRI, RG-1.10). */
|
||||||
|
function toggleSite(siteIri: string, selected: boolean): void {
|
||||||
|
const current = props.modelValue.siteIris
|
||||||
|
const next = selected
|
||||||
|
? [...current, siteIri]
|
||||||
|
: current.filter(iri => iri !== siteIri)
|
||||||
|
update('siteIris', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applique l'exclusivite Prospect / (Livraison|Facturation) au changement. */
|
||||||
|
function toggleFlag(field: keyof AddressFlagsDraft, value: boolean): void {
|
||||||
|
const flags = applyProspectExclusivity(
|
||||||
|
{ isProspect: model.value.isProspect, isDelivery: model.value.isDelivery, isBilling: model.value.isBilling },
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
emit('update:modelValue', { ...props.modelValue, ...flags })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */
|
||||||
|
function enterDegraded(): void {
|
||||||
|
if (!degraded.value) {
|
||||||
|
degraded.value = true
|
||||||
|
emit('degraded')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||||
|
async function onPostalCodeChange(value: string): Promise<void> {
|
||||||
|
update('postalCode', value)
|
||||||
|
|
||||||
|
if (degraded.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
|
if (digits.length < 5) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const suggestions = await autocomplete.searchCity(digits)
|
||||||
|
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
enterDegraded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||||
|
async function onAddressSearch(query: string): Promise<void> {
|
||||||
|
if (degraded.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addressLoading.value = true
|
||||||
|
try {
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
enterDegraded()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
addressLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection d'une suggestion d'adresse → remplit rue + ville + CP.
|
||||||
|
* Le type d'option suit le contrat MalioInputAutocomplete ({ label, value }).
|
||||||
|
*/
|
||||||
|
function onAddressSelect(option: { label: string, value: string | number } | null): void {
|
||||||
|
if (option === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
||||||
|
if (!suggestion) {
|
||||||
|
update('street', String(option.value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
street: suggestion.street,
|
||||||
|
city: suggestion.city,
|
||||||
|
postalCode: suggestion.postalCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative 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)]">
|
||||||
|
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||||
|
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
|
||||||
|
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="removable && !readonly"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.lastName"
|
||||||
|
:label="t('commercial.clients.form.contact.lastName')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('lastName', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.firstName"
|
||||||
|
:label="t('commercial.clients.form.contact.firstName')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.jobTitle"
|
||||||
|
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputEmail
|
||||||
|
:model-value="model.email"
|
||||||
|
:label="t('commercial.clients.form.contact.email')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('email', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
:model-value="model.phonePrimary"
|
||||||
|
:label="t('commercial.clients.form.contact.phonePrimary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:addable="!model.hasSecondaryPhone && !readonly"
|
||||||
|
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
||||||
|
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||||
|
@add="revealSecondaryPhone"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="model.hasSecondaryPhone"
|
||||||
|
:model-value="model.phoneSecondary"
|
||||||
|
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
|
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
|
||||||
|
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
|
||||||
|
const PHONE_MASK = '## ## ## ## ##'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Brouillon du contact (v-model). */
|
||||||
|
modelValue: ContactFormDraft
|
||||||
|
/** Titre du bloc (ex: « Contact 1 »). */
|
||||||
|
title: string
|
||||||
|
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
|
||||||
|
removable?: boolean
|
||||||
|
/** Bloc en lecture seule (onglet valide). */
|
||||||
|
readonly?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ContactFormDraft]
|
||||||
|
'remove': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Alias local pour la lisibilite du template.
|
||||||
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||||
|
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revele le 2e numero (RG-1.02/1.20 : max 1 secondaire, le « + » disparait). */
|
||||||
|
function revealSecondaryPhone(): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom).
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: mockPatch,
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { useClient } = await import('../useClient')
|
||||||
|
|
||||||
|
const SAMPLE = { '@id': '/api/clients/42', id: 42, companyName: 'ACME', isArchived: false }
|
||||||
|
|
||||||
|
describe('useClient', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
mockGet.mockResolvedValue(SAMPLE)
|
||||||
|
mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge le detail via GET /clients/{id} en Hydra, sans toast', async () => {
|
||||||
|
const { client, load } = useClient(42)
|
||||||
|
await load()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(
|
||||||
|
'/clients/42',
|
||||||
|
{},
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
toast: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(client.value).toEqual(SAMPLE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bascule loading pendant le chargement et le retombe a false', async () => {
|
||||||
|
const { loading, load } = useClient(42)
|
||||||
|
const promise = load()
|
||||||
|
expect(loading.value).toBe(true)
|
||||||
|
await promise
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marque error et laisse client null si le GET echoue (404...)', async () => {
|
||||||
|
mockGet.mockRejectedValueOnce(new Error('not found'))
|
||||||
|
const { client, error, load } = useClient(99)
|
||||||
|
await load()
|
||||||
|
expect(error.value).toBe(true)
|
||||||
|
expect(client.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => {
|
||||||
|
// 1er GET = chargement initial, 2e GET = rechargement post-archivage.
|
||||||
|
mockGet.mockResolvedValueOnce(SAMPLE)
|
||||||
|
mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true })
|
||||||
|
const { client, load, archive } = useClient(42)
|
||||||
|
await load()
|
||||||
|
await archive()
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/clients/42',
|
||||||
|
{ isArchived: true },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
// Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(2)
|
||||||
|
expect(client.value?.isArchived).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
|
||||||
|
const { load, restore } = useClient(42)
|
||||||
|
await load()
|
||||||
|
await restore()
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/clients/42',
|
||||||
|
{ isArchived: false },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propage l\'erreur (ex: 409 conflit homonyme RG-1.23) au lieu de l\'avaler', async () => {
|
||||||
|
const conflict = { response: { status: 409 } }
|
||||||
|
mockPatch.mockRejectedValueOnce(conflict)
|
||||||
|
const { load, restore } = useClient(42)
|
||||||
|
await load()
|
||||||
|
await expect(restore()).rejects.toBe(conflict)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
||||||
|
// les appels de chargement des referentiels et simuler un endpoint en echec
|
||||||
|
// (ex: 403 sur /categories pour un role sans la permission de lecture).
|
||||||
|
// Meme pattern que useClientsRepository.spec.ts.
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import APRES le stub pour que useApi soit bien resolu au top-level du module.
|
||||||
|
const { useClientReferentials } = await import('../useClientReferentials')
|
||||||
|
|
||||||
|
describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un referentiel en echec (403) ne vide QUE son select, pas les autres', async () => {
|
||||||
|
// /categories rejette (simulateur d'un 403), tous les autres repondent.
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/categories') {
|
||||||
|
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/x/1', code: 'X', label: 'Libelle X' }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const refs = useClientReferentials()
|
||||||
|
// loadCommon ne doit JAMAIS rejeter : l'echec d'un referentiel est isole.
|
||||||
|
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.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
||||||
|
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
||||||
|
|
||||||
|
// Seul le select en echec reste vide.
|
||||||
|
expect(refs.categories.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge tous les referentiels quand tout repond', async () => {
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/categories') {
|
||||||
|
return Promise.resolve({
|
||||||
|
member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/sites') {
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ member: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
const refs = useClientReferentials()
|
||||||
|
await refs.loadCommon()
|
||||||
|
|
||||||
|
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' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
import type { Client } from '../useClientsRepository'
|
||||||
|
|
||||||
|
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
||||||
|
// les appels declenches par usePaginatedList (que useClientsRepository enveloppe)
|
||||||
|
// et controler les reponses. Meme pattern que useCategoriesAdmin.spec.ts.
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import APRES le stub pour que useApi soit bien resolu au top-level du module.
|
||||||
|
const { useClientsRepository } = await import('../useClientsRepository')
|
||||||
|
|
||||||
|
/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */
|
||||||
|
function makeHydra(total: number): HydraCollection<Client> {
|
||||||
|
return { totalItems: total, member: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useClientsRepository', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
||||||
|
mockGet.mockResolvedValue(makeHydra(25))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cible la ressource /clients en page 1 par defaut', async () => {
|
||||||
|
const repo = useClientsRepository()
|
||||||
|
await repo.fetch()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
|
'/clients',
|
||||||
|
{ page: 1, itemsPerPage: 10 },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pousse les filtres du drawer (categories multi, sites, archives) et retombe en page 1', async () => {
|
||||||
|
const repo = useClientsRepository()
|
||||||
|
await repo.fetch()
|
||||||
|
await repo.goToPage(2)
|
||||||
|
expect(repo.currentPage.value).toBe(2)
|
||||||
|
|
||||||
|
await repo.setFilters(
|
||||||
|
{
|
||||||
|
search: 'acme',
|
||||||
|
'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'],
|
||||||
|
'siteId[]': ['1', '2'],
|
||||||
|
archivedOnly: true,
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(repo.currentPage.value).toBe(1)
|
||||||
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
|
'/clients',
|
||||||
|
{
|
||||||
|
search: 'acme',
|
||||||
|
'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'],
|
||||||
|
'siteId[]': ['1', '2'],
|
||||||
|
archivedOnly: true,
|
||||||
|
page: 1,
|
||||||
|
itemsPerPage: 10,
|
||||||
|
},
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||||
|
const repo = useClientsRepository()
|
||||||
|
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
|
||||||
|
await repo.setFilters({}, { replace: true })
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
|
'/clients',
|
||||||
|
{ page: 1, itemsPerPage: 10 },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
||||||
|
* client », 1.11). Lit le detail embarque via `GET /api/clients/{id}` (contacts /
|
||||||
|
* adresses / ribs sous `client:item:read` / `client:read:accounting`) et expose
|
||||||
|
* les bascules d'archivage (PATCH `isArchived` SEUL — tout autre champ => 422).
|
||||||
|
*
|
||||||
|
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
|
||||||
|
* Hydra complet (sans lui, API Platform 4 renvoie une representation reduite).
|
||||||
|
*
|
||||||
|
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs
|
||||||
|
* d'archivage/restauration (notamment le 409 RG-1.23 : homonyme actif a la
|
||||||
|
* restauration) sont PROPAGEES a l'appelant, qui decide du toast a afficher.
|
||||||
|
*/
|
||||||
|
export function useClient(id: number | string) {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const client = ref<ClientDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
|
||||||
|
function fetchDetail(): Promise<ClientDetail> {
|
||||||
|
return api.get<ClientDetail>(
|
||||||
|
`/clients/${id}`,
|
||||||
|
{},
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge le detail du client. En cas d'echec : `error = true`, `client = null`. */
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = false
|
||||||
|
try {
|
||||||
|
client.value = await fetchDetail()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
error.value = true
|
||||||
|
client.value = null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422),
|
||||||
|
* puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe
|
||||||
|
* `client:read` (ni l'embed contacts/adresses/ribs ni les libelles des
|
||||||
|
* referentiels comptables), un simple merge laisserait l'affichage incoherent.
|
||||||
|
* Toute erreur (notamment le 409 d'homonyme actif a la restauration, RG-1.23)
|
||||||
|
* est propagee a l'appelant AVANT le rechargement.
|
||||||
|
*/
|
||||||
|
async function setArchived(isArchived: boolean): Promise<void> {
|
||||||
|
await api.patch(`/clients/${id}`, { isArchived }, { toast: false })
|
||||||
|
client.value = await fetchDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
load,
|
||||||
|
archive: () => setArchived(true),
|
||||||
|
restore: () => setArchived(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
|
||||||
|
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
|
||||||
|
* reglement, banques, et les listes distributeurs / courtiers.
|
||||||
|
*
|
||||||
|
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
||||||
|
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
|
||||||
|
* l'en-tete `Accept: application/ld+json` impose par API Platform 4 pour obtenir
|
||||||
|
* l'enveloppe Hydra (`member`). Les valeurs d'option sont les IRI Hydra (`@id`)
|
||||||
|
* pour pouvoir etre renvoyees telles quelles dans les payloads POST/PATCH
|
||||||
|
* (relations ManyToOne / ManyToMany).
|
||||||
|
*
|
||||||
|
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox ({ label, value }). */
|
||||||
|
export interface RefOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option de type de reglement enrichie de son code stable (RG-1.12 / RG-1.13). */
|
||||||
|
export interface PaymentTypeOption extends RefOption {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option de categorie enrichie de son code stable (filtrage RG-1.29 cote adresse). */
|
||||||
|
export interface CategoryOption extends RefOption {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option de client (distributeur / courtier) — value = IRI du client lie. */
|
||||||
|
export type ClientOption = RefOption
|
||||||
|
|
||||||
|
interface HydraMember {
|
||||||
|
'@id': string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteMember extends HydraMember {
|
||||||
|
name: string
|
||||||
|
postalCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReferentialMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientMember extends HydraMember {
|
||||||
|
companyName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
|
export function useClientReferentials() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const categories = ref<CategoryOption[]>([])
|
||||||
|
const sites = ref<RefOption[]>([])
|
||||||
|
const tvaModes = ref<RefOption[]>([])
|
||||||
|
const paymentDelays = ref<RefOption[]>([])
|
||||||
|
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||||
|
const banks = ref<RefOption[]>([])
|
||||||
|
const distributors = ref<ClientOption[]>([])
|
||||||
|
const brokers = ref<ClientOption[]>([])
|
||||||
|
|
||||||
|
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||||
|
async function fetchAll<T extends HydraMember>(
|
||||||
|
url: string,
|
||||||
|
query: Record<string, string | string[]> = {},
|
||||||
|
): Promise<T[]> {
|
||||||
|
const res = await api.get<{ member?: T[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false', ...query },
|
||||||
|
{ headers: LD_JSON_HEADERS, toast: false },
|
||||||
|
)
|
||||||
|
return res.member ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge en parallele les referentiels communs (hors distributeurs/courtiers,
|
||||||
|
* charges a la demande selon la relation choisie).
|
||||||
|
*
|
||||||
|
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole.
|
||||||
|
* Necessaire pour les roles metier qui n'ont pas toutes les permissions de
|
||||||
|
* lecture — ex. Compta a `commercial.clients.view` (donc /tva_modes, /banks...
|
||||||
|
* accessibles) mais PAS `catalog.categories.view` ni `sites.view` : sans
|
||||||
|
* isolation, le 403 sur /categories ferait echouer tout le bloc et viderait
|
||||||
|
* les selects comptables dont Compta a besoin sur l'ecran de modification.
|
||||||
|
* Un referentiel en echec reste simplement vide (l'ecran d'edition complete
|
||||||
|
* l'affichage des valeurs courantes depuis l'embed du detail client).
|
||||||
|
*/
|
||||||
|
async function loadCommon(): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
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) })) }),
|
||||||
|
fetchAll<ReferentialMember>('/tva_modes')
|
||||||
|
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
|
||||||
|
fetchAll<ReferentialMember>('/payment_delays')
|
||||||
|
.then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }),
|
||||||
|
fetchAll<ReferentialMember>('/payment_types')
|
||||||
|
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
|
||||||
|
fetchAll<ReferentialMember>('/banks')
|
||||||
|
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
|
||||||
|
async function loadDistributors(): Promise<void> {
|
||||||
|
if (distributors.value.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'DISTRIBUTEUR' })
|
||||||
|
distributors.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste des clients pouvant etre choisis comme courtier (code COURTIER). */
|
||||||
|
async function loadBrokers(): Promise<void> {
|
||||||
|
if (brokers.value.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'COURTIER' })
|
||||||
|
brokers.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
sites,
|
||||||
|
tvaModes,
|
||||||
|
paymentDelays,
|
||||||
|
paymentTypes,
|
||||||
|
banks,
|
||||||
|
distributors,
|
||||||
|
brokers,
|
||||||
|
loadCommon,
|
||||||
|
loadDistributors,
|
||||||
|
loadBrokers,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site Starseed rattache a une adresse du client, tel qu'embarque en LISTE
|
||||||
|
* (groupe site:read) pour la colonne « Site(s) » du Repertoire (badges colores).
|
||||||
|
*/
|
||||||
|
export interface ClientSite {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorie rattachee au client, embarquee en LISTE (groupe category:read).
|
||||||
|
* Seul le `code` (stable, MAJUSCULE — ERP-78) est affiche dans la colonne
|
||||||
|
* « Catégories ». Les autres champs sont presents mais non utilises ici.
|
||||||
|
*/
|
||||||
|
export interface ClientCategory {
|
||||||
|
code: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'un client pour le Repertoire (datatable). Volontairement
|
||||||
|
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
||||||
|
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-62).
|
||||||
|
*/
|
||||||
|
export interface Client {
|
||||||
|
id: number
|
||||||
|
companyName: string
|
||||||
|
categories: ClientCategory[]
|
||||||
|
sites: ClientSite[]
|
||||||
|
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||||
|
updatedAt: string | null
|
||||||
|
isArchived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repertoire clients (ERP-62) — simple enveloppe de `usePaginatedList<Client>`
|
||||||
|
* sur la ressource `/clients` (RG-13 : pagination serveur obligatoire ; jamais
|
||||||
|
* de chargement integral en memoire).
|
||||||
|
*
|
||||||
|
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
||||||
|
* via `setFilters` du composable partage — la remise en page 1 est garantie.
|
||||||
|
*
|
||||||
|
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||||
|
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||||
|
* `usePaginatedList` (cf. sites.vue / categories.vue). Aucun reset au logout a
|
||||||
|
* gerer.
|
||||||
|
*/
|
||||||
|
export function useClientsRepository() {
|
||||||
|
return usePaginatedList<Client>({ url: '/clients' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,879 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour repertoire + nom du client. -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Etats de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.clients.edit.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.clients.edit.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="client">
|
||||||
|
<!-- ── Bloc principal (pre-rempli, editable si `manage`) ──────────────
|
||||||
|
Decision Tristan : on conserve le bloc principal en modification
|
||||||
|
(« pour ne pas tout casser »), edite via son propre PATCH scope
|
||||||
|
sur le groupe client:write:main. Readonly pour les roles sans
|
||||||
|
`manage` (ex. Compta). -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="main.companyName"
|
||||||
|
:label="t('commercial.clients.form.main.companyName')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="main.categoryIris"
|
||||||
|
:options="mainCategoryOptions"
|
||||||
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="main.relationType"
|
||||||
|
:options="relationOptions"
|
||||||
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
|
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
@update:model-value="onRelationChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="main.relationType === 'courtier'"
|
||||||
|
:model-value="main.brokerIri"
|
||||||
|
:options="brokerOptions"
|
||||||
|
:label="t('commercial.clients.form.main.brokerName')"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="main.relationType === 'distributeur'"
|
||||||
|
:model-value="main.distributorIri"
|
||||||
|
:options="distributorOptions"
|
||||||
|
:label="t('commercial.clients.form.main.distributorName')"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
v-model="main.triageService"
|
||||||
|
:label="t('commercial.clients.form.main.triageService')"
|
||||||
|
group-class="self-center"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.edit.save')"
|
||||||
|
:disabled="!isMainValid || mainSubmitting"
|
||||||
|
@click="submitMain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
|
||||||
|
<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)]">
|
||||||
|
<MalioInputTextArea
|
||||||
|
v-model="information.description"
|
||||||
|
:label="t('commercial.clients.form.information.description')"
|
||||||
|
resize="none"
|
||||||
|
group-class="row-span-2 pt-1"
|
||||||
|
text-input="h-full text-lg"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.competitors"
|
||||||
|
:label="t('commercial.clients.form.information.competitors')"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioDate
|
||||||
|
v-model="information.foundedAt"
|
||||||
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.employeesCount"
|
||||||
|
:label="t('commercial.clients.form.information.employeesCount')"
|
||||||
|
:mask="EMPLOYEES_MASK"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="information.revenueAmount"
|
||||||
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.directorName"
|
||||||
|
:label="t('commercial.clients.form.information.directorName')"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="information.profitAmount"
|
||||||
|
:label="t('commercial.clients.form.information.profitAmount')"
|
||||||
|
:disabled="businessReadonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.edit.save')"
|
||||||
|
:disabled="tabSubmitting"
|
||||||
|
@click="submitInformation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Contact -->
|
||||||
|
<template #contact>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ClientContactBlock
|
||||||
|
v-for="(contact, index) in contacts"
|
||||||
|
:key="contact.id ?? `new-${index}`"
|
||||||
|
:model-value="contact"
|
||||||
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
|
:removable="contacts.length > 1"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
|
@remove="askRemoveContact(index)"
|
||||||
|
/>
|
||||||
|
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.contact.add')"
|
||||||
|
:disabled="!canAddContact"
|
||||||
|
@click="addContact"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.edit.save')"
|
||||||
|
:disabled="!canValidateContacts || tabSubmitting"
|
||||||
|
@click="submitContacts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Adresse -->
|
||||||
|
<template #address>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ClientAddressBlock
|
||||||
|
v-for="(address, index) in addresses"
|
||||||
|
:key="address.id ?? `new-${index}`"
|
||||||
|
:model-value="address"
|
||||||
|
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||||
|
:category-options="addressCategoryOptions"
|
||||||
|
:site-options="siteOptions"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptions"
|
||||||
|
:removable="addresses.length > 1"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
|
@remove="askRemoveAddress(index)"
|
||||||
|
@degraded="onAddressDegraded"
|
||||||
|
/>
|
||||||
|
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.address.add')"
|
||||||
|
@click="addAddress"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.edit.save')"
|
||||||
|
:disabled="!canValidateAddresses || tabSubmitting"
|
||||||
|
@click="submitAddresses"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Comptabilite (present uniquement si accounting.view ;
|
||||||
|
editable uniquement si accounting.manage). -->
|
||||||
|
<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">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.siren"
|
||||||
|
:label="t('commercial.clients.form.accounting.siren')"
|
||||||
|
:mask="SIREN_MASK"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.accountNumber"
|
||||||
|
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.tvaModeIri"
|
||||||
|
:options="tvaModeOptions"
|
||||||
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@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"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.paymentDelayIri"
|
||||||
|
:options="paymentDelayOptions"
|
||||||
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@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')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@update:model-value="onPaymentTypeChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="isBankRequired"
|
||||||
|
:model-value="accounting.bankIri"
|
||||||
|
:options="bankOptions"
|
||||||
|
:label="t('commercial.clients.form.accounting.bank')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR (RG-1.13). -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in ribs"
|
||||||
|
:key="rib.id ?? `new-${index}`"
|
||||||
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
|
>
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="!accountingReadonly"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.label"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.bic"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.iban"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.accounting.addRib')"
|
||||||
|
@click="addRib"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.edit.save')"
|
||||||
|
:disabled="!canValidateAccounting || tabSubmitting"
|
||||||
|
@click="submitAccounting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</MalioTabList>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
|
||||||
|
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<p>{{ confirmModal.message }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('commercial.clients.form.confirmDelete.cancel')"
|
||||||
|
@click="confirmModal.open = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('commercial.clients.form.confirmDelete.confirm')"
|
||||||
|
@click="runConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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 {
|
||||||
|
canEditClient,
|
||||||
|
categoryOptionsOf,
|
||||||
|
referentialOptionOf,
|
||||||
|
siteOptionsOf,
|
||||||
|
mapContactToDraft,
|
||||||
|
mapAddressToDraft,
|
||||||
|
mapRibToDraft,
|
||||||
|
type ClientDetail,
|
||||||
|
} from '~/modules/commercial/utils/clientConsultation'
|
||||||
|
import {
|
||||||
|
buildAccountingPayload,
|
||||||
|
buildAddressPayload,
|
||||||
|
buildContactPayload,
|
||||||
|
buildInformationPayload,
|
||||||
|
buildMainPayload,
|
||||||
|
buildRibPayload,
|
||||||
|
mapAccountingFormDraft,
|
||||||
|
mapInformationDraft,
|
||||||
|
mapMainDraft,
|
||||||
|
resolveTabEditability,
|
||||||
|
type AccountingFormDraft,
|
||||||
|
type ClientEditAbilities,
|
||||||
|
type InformationFormDraft,
|
||||||
|
type MainFormDraft,
|
||||||
|
} from '~/modules/commercial/utils/clientEdit'
|
||||||
|
import {
|
||||||
|
buildClientFormTabKeys,
|
||||||
|
hasAtLeastOneValidContact,
|
||||||
|
isBankRequiredForPaymentType,
|
||||||
|
isBillingEmailRequired,
|
||||||
|
isContactNamed,
|
||||||
|
isRibRequiredForPaymentType,
|
||||||
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
|
import {
|
||||||
|
emptyAddress,
|
||||||
|
emptyContact,
|
||||||
|
emptyRib,
|
||||||
|
type AddressFormDraft,
|
||||||
|
type ContactFormDraft,
|
||||||
|
type RibFormDraft,
|
||||||
|
} from '~/modules/commercial/types/clientForm'
|
||||||
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
|
const SIREN_MASK = '#########'
|
||||||
|
const EMPLOYEES_MASK = '#######'
|
||||||
|
|
||||||
|
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
|
||||||
|
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const toast = useToast()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can, canAny } = usePermissions()
|
||||||
|
|
||||||
|
// Gating de la route : l'edition exige de pouvoir editer au moins un onglet
|
||||||
|
// (`manage` OU `accounting.manage`). Usine et roles en lecture seule sont
|
||||||
|
// rediriges vers le repertoire (lui-meme protege).
|
||||||
|
if (!canEditClient(canAny)) {
|
||||||
|
await navigateTo('/clients')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = route.params.id as string
|
||||||
|
|
||||||
|
const { client, loading, error, load } = useClient(clientId)
|
||||||
|
const referentials = useClientReferentials()
|
||||||
|
|
||||||
|
// ── Permissions / editabilite par zone (option 1 ERP-74) ────────────────────
|
||||||
|
const abilities = computed<ClientEditAbilities>(() => ({
|
||||||
|
canManage: can('commercial.clients.manage'),
|
||||||
|
canAccountingView: can('commercial.clients.accounting.view'),
|
||||||
|
canAccountingManage: can('commercial.clients.accounting.manage'),
|
||||||
|
}))
|
||||||
|
const editability = computed(() => resolveTabEditability(abilities.value))
|
||||||
|
// Bloc principal + onglets Information / Contact / Adresse.
|
||||||
|
const businessReadonly = computed(() => !editability.value.businessEditable)
|
||||||
|
const canAccountingView = computed(() => editability.value.accountingVisible)
|
||||||
|
const accountingReadonly = computed(() => !editability.value.accountingEditable)
|
||||||
|
|
||||||
|
const headerTitle = computed(() => client.value?.companyName ?? t('commercial.clients.edit.title'))
|
||||||
|
|
||||||
|
// ── Brouillons editables (pre-remplis depuis le detail) ─────────────────────
|
||||||
|
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
|
||||||
|
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
|
||||||
|
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
|
||||||
|
const contacts = ref<ContactFormDraft[]>([])
|
||||||
|
const addresses = ref<AddressFormDraft[]>([])
|
||||||
|
const ribs = ref<RibFormDraft[]>([])
|
||||||
|
|
||||||
|
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
||||||
|
const removedContactIds = ref<number[]>([])
|
||||||
|
const removedAddressIds = ref<number[]>([])
|
||||||
|
const removedRibIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const mainSubmitting = ref(false)
|
||||||
|
const tabSubmitting = ref(false)
|
||||||
|
const addressDegradedNotified = ref(false)
|
||||||
|
|
||||||
|
/** Recopie le detail charge dans les brouillons editables. */
|
||||||
|
function hydrate(detail: ClientDetail): void {
|
||||||
|
Object.assign(main, mapMainDraft(detail))
|
||||||
|
Object.assign(information, mapInformationDraft(detail))
|
||||||
|
Object.assign(accounting, mapAccountingFormDraft(detail))
|
||||||
|
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(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Options de selects (referentiels UNION valeurs courantes de l'embed) ─────
|
||||||
|
// L'union garantit que les valeurs deja posees s'affichent meme quand le
|
||||||
|
// referentiel complet n'est pas chargeable (roles metier sans
|
||||||
|
// catalog.categories.view / sites.view → 403, cf. matrice § 2.7).
|
||||||
|
function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[] {
|
||||||
|
const seen = new Set(primary.map(o => o.value))
|
||||||
|
return [...primary, ...extra.filter(o => !seen.has(o.value))]
|
||||||
|
}
|
||||||
|
|
||||||
|
const embedCategoryOptions = computed<CategoryOption[]>(() => {
|
||||||
|
const fromClient = categoryOptionsOf(client.value?.categories)
|
||||||
|
const fromAddresses = (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
|
||||||
|
return mergeOptions(fromClient, fromAddresses)
|
||||||
|
})
|
||||||
|
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
|
||||||
|
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
||||||
|
const addressCategoryOptions = computed(() =>
|
||||||
|
mainCategoryOptions.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const embedSiteOptions = computed<RefOption[]>(() =>
|
||||||
|
mergeOptions([], (client.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
|
||||||
|
)
|
||||||
|
const siteOptions = computed(() => mergeOptions(referentials.sites.value, embedSiteOptions.value))
|
||||||
|
|
||||||
|
// Contacts deja persistes (iri non null), rattachables a une adresse (M2M).
|
||||||
|
const contactOptions = computed<RefOption[]>(() =>
|
||||||
|
contacts.value
|
||||||
|
.filter(c => c.iri !== null)
|
||||||
|
.map(c => ({
|
||||||
|
value: c.iri as string,
|
||||||
|
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const countryOptions: RefOption[] = [
|
||||||
|
{ value: 'France', label: 'France' },
|
||||||
|
{ value: 'Espagne', label: 'Espagne' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const relationOptions = computed<RefOption[]>(() => [
|
||||||
|
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
|
||||||
|
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Distributeur / courtier : referentiel charge a la demande UNION valeur courante.
|
||||||
|
const currentDistributorOption = computed<RefOption[]>(() => {
|
||||||
|
const d = client.value?.distributor
|
||||||
|
return d && typeof d === 'object' ? [{ value: d['@id'], label: d.companyName ?? d['@id'] }] : []
|
||||||
|
})
|
||||||
|
const currentBrokerOption = computed<RefOption[]>(() => {
|
||||||
|
const b = client.value?.broker
|
||||||
|
return b && typeof b === 'object' ? [{ value: b['@id'], label: b.companyName ?? b['@id'] }] : []
|
||||||
|
})
|
||||||
|
const distributorOptions = computed(() => mergeOptions(referentials.distributors.value, currentDistributorOption.value))
|
||||||
|
const brokerOptions = computed(() => mergeOptions(referentials.brokers.value, currentBrokerOption.value))
|
||||||
|
|
||||||
|
// Selects comptables : referentiel UNION valeur courante de l'embed (libelle).
|
||||||
|
const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(client.value?.tvaMode)))
|
||||||
|
const paymentDelayOptions = computed(() => mergeOptions(referentials.paymentDelays.value, referentialOptionOf(client.value?.paymentDelay)))
|
||||||
|
const paymentTypeOptions = computed(() => mergeOptions(
|
||||||
|
referentials.paymentTypes.value.map(p => ({ value: p.value, label: p.label })),
|
||||||
|
referentialOptionOf(client.value?.paymentType),
|
||||||
|
))
|
||||||
|
const bankOptions = computed(() => mergeOptions(referentials.banks.value, referentialOptionOf(client.value?.bank)))
|
||||||
|
|
||||||
|
// ── Onglets : navigation libre (4 actifs + 4 coquilles, comme la consultation) ─
|
||||||
|
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
||||||
|
|
||||||
|
const TAB_ICONS: Record<string, string> = {
|
||||||
|
information: 'mdi:account-outline',
|
||||||
|
contact: 'mdi:account-box-plus-outline',
|
||||||
|
address: 'mdi:map-marker-outline',
|
||||||
|
transport: 'mdi:truck-delivery-outline',
|
||||||
|
accounting: 'mdi:bank-circle-outline',
|
||||||
|
statistics: 'mdi:finance',
|
||||||
|
reports: 'mdi:file-document-edit-outline',
|
||||||
|
exchanges: 'mdi:account-group-outline',
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||||
|
key,
|
||||||
|
label: t(`commercial.clients.tab.${key}`),
|
||||||
|
icon: TAB_ICONS[key],
|
||||||
|
})))
|
||||||
|
|
||||||
|
const activeTab = ref('information')
|
||||||
|
|
||||||
|
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||||
|
function goBack(): void {
|
||||||
|
router.push(`/clients/${clientId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message d'erreur a afficher : violation 422 / detail renvoye par le serveur,
|
||||||
|
* sinon un libelle generique. Le 409 d'unicite de nom (bloc principal) est
|
||||||
|
* traduit explicitement par l'appelant.
|
||||||
|
*/
|
||||||
|
function apiErrorMessage(e: unknown): string {
|
||||||
|
const data = (e as { data?: unknown })?.data
|
||||||
|
return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void {
|
||||||
|
const status = (e as { response?: { status?: number } })?.response?.status
|
||||||
|
toast.error({
|
||||||
|
title: t('commercial.clients.toast.error'),
|
||||||
|
message: opts.duplicateCompany && status === 409
|
||||||
|
? t('commercial.clients.form.duplicateCompany')
|
||||||
|
: apiErrorMessage(e),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bloc principal ───────────────────────────────────────────────────────────
|
||||||
|
const isMainValid = computed(() => {
|
||||||
|
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
|
||||||
|
const relationValid
|
||||||
|
= main.relationType === null
|
||||||
|
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|
||||||
|
|| (main.relationType === 'courtier' && filled(main.brokerIri))
|
||||||
|
return filled(main.companyName)
|
||||||
|
&& main.categoryIris.length >= 1
|
||||||
|
&& relationValid
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onRelationChange(value: string | number | null): Promise<void> {
|
||||||
|
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
|
||||||
|
main.relationType = relation
|
||||||
|
// Une seule FK remplie a la fois (RG-1.03).
|
||||||
|
if (relation !== 'distributeur') main.distributorIri = null
|
||||||
|
if (relation !== 'courtier') main.brokerIri = null
|
||||||
|
|
||||||
|
if (relation === 'distributeur') await referentials.loadDistributors().catch(() => {})
|
||||||
|
if (relation === 'courtier') await referentials.loadBrokers().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
|
||||||
|
async function submitMain(): Promise<void> {
|
||||||
|
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
|
||||||
|
mainSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
// Reaffiche les valeurs normalisees renvoyees par le serveur.
|
||||||
|
Object.assign(main, mapMainDraft(updated))
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
showError(e, { duplicateCompany: true })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
mainSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Information ───────────────────────────────────────────────────────
|
||||||
|
/** PATCH /clients/{id} — groupe client:write:information UNIQUEMENT. */
|
||||||
|
async function submitInformation(): Promise<void> {
|
||||||
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false })
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
showError(e)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Contact ───────────────────────────────────────────────────────────
|
||||||
|
const canAddContact = computed(() => {
|
||||||
|
const last = contacts.value[contacts.value.length - 1]
|
||||||
|
return last === undefined || isContactNamed(last)
|
||||||
|
})
|
||||||
|
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
|
||||||
|
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
|
||||||
|
|
||||||
|
function addContact(): void {
|
||||||
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveContact(index: number): void {
|
||||||
|
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
||||||
|
const removed = contacts.value[index]
|
||||||
|
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
||||||
|
contacts.value.splice(index, 1)
|
||||||
|
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||||
|
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
||||||
|
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
||||||
|
* collection contacts (endpoints client_contact dedies).
|
||||||
|
*/
|
||||||
|
async function submitContacts(): Promise<void> {
|
||||||
|
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
for (const id of removedContactIds.value) {
|
||||||
|
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
|
||||||
|
}
|
||||||
|
removedContactIds.value = []
|
||||||
|
|
||||||
|
for (const contact of contacts.value) {
|
||||||
|
if (!isContactNamed(contact)) continue
|
||||||
|
const body = buildContactPayload(contact)
|
||||||
|
if (contact.id === null) {
|
||||||
|
const created = await api.post<{ '@id'?: string, id: number }>(
|
||||||
|
`/clients/${clientId}/contacts`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
contact.id = created.id
|
||||||
|
contact.iri = created['@id'] ?? null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
showError(e)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Adresse ───────────────────────────────────────────────────────────
|
||||||
|
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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function addAddress(): void {
|
||||||
|
addresses.value.push(emptyAddress())
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveAddress(index: number): void {
|
||||||
|
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
||||||
|
const removed = addresses.value[index]
|
||||||
|
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
||||||
|
addresses.value.splice(index, 1)
|
||||||
|
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||||
|
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddressDegraded(): void {
|
||||||
|
if (addressDegradedNotified.value) return
|
||||||
|
addressDegradedNotified.value = true
|
||||||
|
toast.warning({
|
||||||
|
title: t('commercial.clients.toast.error'),
|
||||||
|
message: t('commercial.clients.form.address.degraded'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
||||||
|
async function submitAddresses(): Promise<void> {
|
||||||
|
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
for (const id of removedAddressIds.value) {
|
||||||
|
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
|
||||||
|
}
|
||||||
|
removedAddressIds.value = []
|
||||||
|
|
||||||
|
for (const address of addresses.value) {
|
||||||
|
const body = buildAddressPayload(address, isBillingEmailRequired(address))
|
||||||
|
if (address.id === null) {
|
||||||
|
const created = await api.post<{ id: number }>(
|
||||||
|
`/clients/${clientId}/addresses`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
address.id = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
showError(e)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Comptabilite ──────────────────────────────────────────────────────
|
||||||
|
const selectedPaymentTypeCode = computed(() =>
|
||||||
|
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
||||||
|
)
|
||||||
|
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||||
|
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||||
|
|
||||||
|
function onPaymentTypeChange(value: string | number | null): void {
|
||||||
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean {
|
||||||
|
const filled = (v: string | null) => v !== null && v.trim() !== ''
|
||||||
|
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canValidateAccounting = computed(() => {
|
||||||
|
if (isBankRequired.value && accounting.bankIri === null) return false
|
||||||
|
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
function addRib(): void {
|
||||||
|
ribs.value.push(emptyRib())
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveRib(index: number): void {
|
||||||
|
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
||||||
|
const removed = ribs.value[index]
|
||||||
|
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
||||||
|
ribs.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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting,
|
||||||
|
* exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la
|
||||||
|
* sous-ressource. Aucun champ main/information dans le payload (mode strict
|
||||||
|
* RG-1.28 : sinon 403 sur tout le payload).
|
||||||
|
*/
|
||||||
|
async function submitAccounting(): Promise<void> {
|
||||||
|
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
|
||||||
|
|
||||||
|
for (const id of removedRibIds.value) {
|
||||||
|
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||||
|
}
|
||||||
|
removedRibIds.value = []
|
||||||
|
|
||||||
|
for (const rib of ribs.value) {
|
||||||
|
if (!ribIsComplete(rib)) continue
|
||||||
|
const body = buildRibPayload(rib)
|
||||||
|
if (rib.id === null) {
|
||||||
|
const created = await api.post<{ id: number }>(
|
||||||
|
`/clients/${clientId}/ribs`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
rib.id = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
showError(e)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal de confirmation generique ──────────────────────────────────────────
|
||||||
|
const confirmModal = reactive({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
action: null as null | (() => void),
|
||||||
|
})
|
||||||
|
|
||||||
|
function askConfirm(message: string, action: () => void): void {
|
||||||
|
confirmModal.message = message
|
||||||
|
confirmModal.action = action
|
||||||
|
confirmModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function runConfirm(): void {
|
||||||
|
confirmModal.action?.()
|
||||||
|
confirmModal.action = null
|
||||||
|
confirmModal.open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: headerTitle })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Referentiels en best-effort (echec non bloquant : l'embed alimente les
|
||||||
|
// libelles des valeurs courantes).
|
||||||
|
referentials.loadCommon().catch(() => {})
|
||||||
|
await load()
|
||||||
|
if (client.value) hydrate(client.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
|
||||||
|
|
||||||
|
<!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. -->
|
||||||
|
<div class="ml-auto flex items-center gap-12">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canEdit"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:pencil-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.action.edit')"
|
||||||
|
@click="goEdit"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="showArchive"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:archive-arrow-down-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.action.archive')"
|
||||||
|
@click="askToggleArchive"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="showRestore"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:archive-arrow-up-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.action.restore')"
|
||||||
|
@click="askToggleArchive"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Etats de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.clients.consultation.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.clients.consultation.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="client">
|
||||||
|
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="client.companyName"
|
||||||
|
:label="t('commercial.clients.form.main.companyName')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="categoryIris"
|
||||||
|
:options="mainCategoryOptions"
|
||||||
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="relation.type"
|
||||||
|
:options="relationOptions"
|
||||||
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
|
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||||
|
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"
|
||||||
|
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
:model-value="client.triageService === true"
|
||||||
|
:label="t('commercial.clients.form.main.triageService')"
|
||||||
|
group-class="self-center"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
||||||
|
<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)]">
|
||||||
|
<MalioInputTextArea
|
||||||
|
:model-value="information.description"
|
||||||
|
:label="t('commercial.clients.form.information.description')"
|
||||||
|
resize="none"
|
||||||
|
group-class="row-span-2 pt-1"
|
||||||
|
text-input="h-full text-lg"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="information.competitors"
|
||||||
|
:label="t('commercial.clients.form.information.competitors')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioDate
|
||||||
|
:model-value="information.foundedAt"
|
||||||
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="information.employeesCount"
|
||||||
|
:label="t('commercial.clients.form.information.employeesCount')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
:model-value="information.revenueAmount"
|
||||||
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="information.directorName"
|
||||||
|
:label="t('commercial.clients.form.information.directorName')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
:model-value="information.profitAmount"
|
||||||
|
:label="t('commercial.clients.form.information.profitAmount')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Contact -->
|
||||||
|
<template #contact>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ClientContactBlock
|
||||||
|
v-for="(contact, index) in contacts"
|
||||||
|
:key="contact.id ?? index"
|
||||||
|
:model-value="contact"
|
||||||
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Adresse -->
|
||||||
|
<template #address>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ClientAddressBlock
|
||||||
|
v-for="(view, index) in addressViews"
|
||||||
|
:key="view.draft.id ?? index"
|
||||||
|
:model-value="view.draft"
|
||||||
|
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||||
|
:category-options="view.categoryOptions"
|
||||||
|
:site-options="allSiteOptions"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptions"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||||
|
<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">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="accounting.siren"
|
||||||
|
:label="t('commercial.clients.form.accounting.siren')"
|
||||||
|
:mask="SIREN_MASK"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="accounting.accountNumber"
|
||||||
|
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.tvaModeIri"
|
||||||
|
:options="tvaModeOptions"
|
||||||
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||||
|
empty-option-label=""
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="accounting.nTva"
|
||||||
|
:label="t('commercial.clients.form.accounting.nTva')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.paymentDelayIri"
|
||||||
|
:options="paymentDelayOptions"
|
||||||
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||||
|
empty-option-label=""
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.paymentTypeIri"
|
||||||
|
:options="paymentTypeOptions"
|
||||||
|
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||||
|
empty-option-label=""
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="accounting.bankIri"
|
||||||
|
:model-value="accounting.bankIri"
|
||||||
|
:options="bankOptions"
|
||||||
|
:label="t('commercial.clients.form.accounting.bank')"
|
||||||
|
empty-option-label=""
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocs RIB (0..n), lecture seule. -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in ribs"
|
||||||
|
: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">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="rib.label"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="rib.bic"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="rib.iban"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</MalioTabList>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Modal de confirmation Archiver / Restaurer. -->
|
||||||
|
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">
|
||||||
|
{{ isArchived ? t('commercial.clients.consultation.confirmRestore.title') : t('commercial.clients.consultation.confirmArchive.title') }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
<p>{{ isArchived ? t('commercial.clients.consultation.confirmRestore.message') : t('commercial.clients.consultation.confirmArchive.message') }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('commercial.clients.form.confirmDelete.cancel')"
|
||||||
|
@click="confirmOpen = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:variant="isArchived ? 'primary' : 'danger'"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('commercial.clients.form.confirmDelete.confirm')"
|
||||||
|
:disabled="toggling"
|
||||||
|
@click="confirmToggleArchive"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||||
|
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
|
||||||
|
import {
|
||||||
|
canEditClient,
|
||||||
|
categoryOptionsOf,
|
||||||
|
contactOptionsOf,
|
||||||
|
mapAccountingDraft,
|
||||||
|
mapAddressView,
|
||||||
|
mapContactToDraft,
|
||||||
|
mapRibToDraft,
|
||||||
|
referentialOptionOf,
|
||||||
|
relationOf,
|
||||||
|
showArchiveAction,
|
||||||
|
showRestoreAction,
|
||||||
|
type ClientDetail,
|
||||||
|
type SelectOption,
|
||||||
|
} from '~/modules/commercial/utils/clientConsultation'
|
||||||
|
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
|
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||||
|
const SIREN_MASK = '#########'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
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.
|
||||||
|
if (!can('commercial.clients.view')) {
|
||||||
|
await navigateTo('/clients')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = route.params.id as string
|
||||||
|
|
||||||
|
const { client, loading, error, load, archive, restore } = useClient(clientId)
|
||||||
|
|
||||||
|
// ── Permissions / visibilite des actions ───────────────────────────────────
|
||||||
|
const canAccountingView = computed(() => can('commercial.clients.accounting.view'))
|
||||||
|
const canEdit = computed(() => canEditClient(canAny))
|
||||||
|
const isArchived = computed(() => client.value?.isArchived === true)
|
||||||
|
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
|
||||||
|
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
|
||||||
|
|
||||||
|
const headerTitle = computed(() => client.value?.companyName ?? t('commercial.clients.consultation.title'))
|
||||||
|
|
||||||
|
// ── Donnees derivees du payload (lecture seule) ────────────────────────────
|
||||||
|
const relation = computed(() => (client.value ? relationOf(client.value) : { type: null, name: null }))
|
||||||
|
const categoryIris = computed(() => (client.value?.categories ?? []).map(c => c['@id']))
|
||||||
|
|
||||||
|
const information = computed(() => ({
|
||||||
|
description: client.value?.description ?? null,
|
||||||
|
competitors: client.value?.competitors ?? null,
|
||||||
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime renvoye.
|
||||||
|
foundedAt: client.value?.foundedAt ? client.value.foundedAt.slice(0, 10) : null,
|
||||||
|
employeesCount: client.value?.employeesCount != null ? String(client.value.employeesCount) : null,
|
||||||
|
revenueAmount: client.value?.revenueAmount ?? null,
|
||||||
|
profitAmount: client.value?.profitAmount ?? null,
|
||||||
|
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()]
|
||||||
|
})
|
||||||
|
// 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()]
|
||||||
|
})
|
||||||
|
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
||||||
|
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
|
||||||
|
|
||||||
|
// ── Options des selects (construites depuis l'EMBED, jamais via un GET de
|
||||||
|
// referentiel : /categories et /sites sont en 403 pour les roles metier
|
||||||
|
// non-admin, ce qui laisserait les libelles vides). ───────────────────────
|
||||||
|
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') },
|
||||||
|
])
|
||||||
|
|
||||||
|
const countryOptions: SelectOption[] = [
|
||||||
|
{ value: 'France', label: 'France' },
|
||||||
|
{ value: 'Espagne', label: 'Espagne' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Selects comptables : libelle issu de l'embed (option unique ou vide).
|
||||||
|
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
|
||||||
|
const paymentDelayOptions = computed(() => referentialOptionOf(client.value?.paymentDelay))
|
||||||
|
const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paymentType))
|
||||||
|
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
|
||||||
|
|
||||||
|
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
|
||||||
|
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
|
||||||
|
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
|
||||||
|
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
||||||
|
|
||||||
|
const TAB_ICONS: Record<string, string> = {
|
||||||
|
information: 'mdi:account-outline',
|
||||||
|
contact: 'mdi:account-box-plus-outline',
|
||||||
|
address: 'mdi:map-marker-outline',
|
||||||
|
transport: 'mdi:truck-delivery-outline',
|
||||||
|
accounting: 'mdi:bank-circle-outline',
|
||||||
|
statistics: 'mdi:finance',
|
||||||
|
reports: 'mdi:file-document-edit-outline',
|
||||||
|
exchanges: 'mdi:account-group-outline',
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||||
|
key,
|
||||||
|
label: t(`commercial.clients.tab.${key}`),
|
||||||
|
icon: TAB_ICONS[key],
|
||||||
|
})))
|
||||||
|
|
||||||
|
const activeTab = ref('information')
|
||||||
|
|
||||||
|
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/clients')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goEdit(): void {
|
||||||
|
router.push(`/clients/${clientId}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Archivage / Restauration ────────────────────────────────────────────────
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const toggling = ref(false)
|
||||||
|
|
||||||
|
function askToggleArchive(): void {
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirme l'archivage ou la restauration (PATCH isArchived seul). Gere le 409
|
||||||
|
* de conflit d'homonyme actif a la restauration (RG-1.23) avec un message dedie.
|
||||||
|
*/
|
||||||
|
async function confirmToggleArchive(): Promise<void> {
|
||||||
|
if (toggling.value) return
|
||||||
|
toggling.value = true
|
||||||
|
const restoring = isArchived.value
|
||||||
|
try {
|
||||||
|
if (restoring) {
|
||||||
|
await restore()
|
||||||
|
toast.success({ title: t('commercial.clients.toast.restoreSuccess') })
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await archive()
|
||||||
|
toast.success({ title: t('commercial.clients.toast.archiveSuccess') })
|
||||||
|
}
|
||||||
|
confirmOpen.value = false
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
const status = (e as { response?: { status?: number } })?.response?.status
|
||||||
|
toast.error({
|
||||||
|
title: t('commercial.clients.toast.error'),
|
||||||
|
message: restoring && status === 409
|
||||||
|
? t('commercial.clients.toast.restoreConflict')
|
||||||
|
: t('commercial.clients.toast.error'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
toggling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: headerTitle })
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>
|
||||||
|
{{ t('commercial.clients.title') }}
|
||||||
|
<template #actions>
|
||||||
|
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. -->
|
||||||
|
<div class="flex items-center gap-12">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManage"
|
||||||
|
variant="secondary"
|
||||||
|
:label="t('commercial.clients.add')"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
@click="goToCreate"
|
||||||
|
/>
|
||||||
|
<!-- Bouton Filtres a DROITE d'Ajouter : meme design que
|
||||||
|
l'audit-log. Le compteur reflete les filtres actifs. -->
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="tertiary"
|
||||||
|
:label="filterButtonLabel"
|
||||||
|
icon-name="mdi:tune"
|
||||||
|
icon-position="left"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="w-[184px] justify-start gap-4 text-black"
|
||||||
|
@click="openFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Datatable branchee sur usePaginatedList via useClientsRepository :
|
||||||
|
pagination serveur, tri companyName ASC par defaut (cote back). -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
table-class="table-fixed"
|
||||||
|
:empty-message="t('commercial.clients.empty')"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
>
|
||||||
|
<!-- Categories : codes stables separes par une virgule (ERP-78). -->
|
||||||
|
<template #cell-categories="{ item }">
|
||||||
|
{{ formatCategories(item) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sites : badges colores (name + color), agreges des adresses. -->
|
||||||
|
<template #cell-sites="{ item }">
|
||||||
|
<span class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="site in (item.sites as ClientSite[])"
|
||||||
|
:key="site.id"
|
||||||
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white"
|
||||||
|
:style="{ backgroundColor: site.color }"
|
||||||
|
>
|
||||||
|
{{ site.name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Derniere activite : date de derniere modification (updatedAt). -->
|
||||||
|
<template #cell-lastActivity="{ item }">
|
||||||
|
{{ formatLastActivity(item) }}
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-6">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.export')"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="exportXlsx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||||
|
« Appliquer ». Meme pattern que l'audit-log. Etat 100 % local, jamais
|
||||||
|
dans l'URL (regle ABSOLUE n°6). -->
|
||||||
|
<MalioDrawer
|
||||||
|
v-model="filterDrawerOpen"
|
||||||
|
drawer-class="max-w-[450px]"
|
||||||
|
body-class="p-0"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">{{ t('commercial.clients.filters.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioAccordion>
|
||||||
|
<!-- Recherche : nom societe + contact + email (param `search`). -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.clients.filters.search')" value="search">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="draftSearch"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Categories : cases a cocher (multi). Valeur = code stable. -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.clients.filters.categories')" value="categories">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in categoryOptions"
|
||||||
|
:id="`filter-category-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="draftCategoryCodes.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.clients.filters.sites')" value="sites">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in siteOptions"
|
||||||
|
:id="`filter-site-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="draftSiteIds.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.clients.filters.status')" value="status">
|
||||||
|
<MalioCheckbox
|
||||||
|
id="filter-archived-only"
|
||||||
|
:label="t('commercial.clients.filters.archivedOnly')"
|
||||||
|
:model-value="draftArchivedOnly"
|
||||||
|
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
:label="t('commercial.clients.filters.reset')"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.filters.apply')"
|
||||||
|
button-class="w-[170px]"
|
||||||
|
@click="applyFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import type { Client, ClientSite } from '~/modules/commercial/composables/useClientsRepository'
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('commercial.clients.title') })
|
||||||
|
|
||||||
|
// Bouton « Ajouter » reserve a `manage` (POST /clients garde manage seul →
|
||||||
|
// Compta / Usine ne creent pas). « Exporter » et « Filtres » suivent `view`.
|
||||||
|
const canManage = computed(() => can('commercial.clients.manage'))
|
||||||
|
const canView = computed(() => can('commercial.clients.view'))
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: clients,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadClients,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
setFilters,
|
||||||
|
} = useClientsRepository()
|
||||||
|
|
||||||
|
// Mappe les clients en objets « plats » pour MalioDataTable (items typees
|
||||||
|
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||||
|
// implicite, contrairement a l'interface Client. Meme pattern que sites.vue.
|
||||||
|
const rows = computed(() => clients.value.map(client => ({
|
||||||
|
id: client.id,
|
||||||
|
companyName: client.companyName,
|
||||||
|
categories: client.categories,
|
||||||
|
sites: client.sites,
|
||||||
|
updatedAt: client.updatedAt,
|
||||||
|
})))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'companyName', label: t('commercial.clients.column.companyName') },
|
||||||
|
{ key: 'categories', label: t('commercial.clients.column.categories') },
|
||||||
|
{ key: 'sites', label: t('commercial.clients.column.sites') },
|
||||||
|
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Codes des categories du client, separes par une virgule (ERP-78). */
|
||||||
|
function formatCategories(item: Record<string, unknown>): string {
|
||||||
|
const categories = (item.categories as Client['categories']) ?? []
|
||||||
|
return categories.map(c => c.code).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derniere activite : faute de suivi d'activite metier au M1, on affiche la
|
||||||
|
* date de derniere modification de la fiche (updatedAt, expose en liste via
|
||||||
|
* default:read). Format court francais jj/mm/aaaa.
|
||||||
|
*/
|
||||||
|
function formatLastActivity(item: Record<string, unknown>): string {
|
||||||
|
const value = item.updatedAt as string | null | undefined
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString('fr-FR')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clic sur une ligne → ecran Consultation (route a plat /clients/{id}). */
|
||||||
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
|
router.push(`/clients/${item.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate(): void {
|
||||||
|
router.push('/clients/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||||
|
// Deux niveaux d'etat (pattern audit-log) :
|
||||||
|
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||||
|
// uniquement au clic « Appliquer » / « Réinitialiser ».
|
||||||
|
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||||
|
const filterDrawerOpen = ref(false)
|
||||||
|
|
||||||
|
const draftSearch = ref('')
|
||||||
|
const draftCategoryCodes = ref<string[]>([])
|
||||||
|
const draftSiteIds = ref<string[]>([])
|
||||||
|
const draftArchivedOnly = ref(false)
|
||||||
|
|
||||||
|
const appliedSearch = ref('')
|
||||||
|
const appliedCategoryCodes = ref<string[]>([])
|
||||||
|
const appliedSiteIds = ref<string[]>([])
|
||||||
|
const appliedArchivedOnly = ref(false)
|
||||||
|
|
||||||
|
// Options des selects multi, chargees une fois (referentiels courts).
|
||||||
|
const categoryOptions = ref<FilterOption[]>([])
|
||||||
|
const siteOptions = ref<FilterOption[]>([])
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
if (appliedSearch.value.trim() !== '') count++
|
||||||
|
if (appliedCategoryCodes.value.length > 0) count++
|
||||||
|
if (appliedSiteIds.value.length > 0) count++
|
||||||
|
if (appliedArchivedOnly.value) count++
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterButtonLabel = computed(() => {
|
||||||
|
const base = t('commercial.clients.filters.title')
|
||||||
|
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
|
||||||
|
// reouverture reflete les filtres actifs.
|
||||||
|
function openFilters(): void {
|
||||||
|
draftSearch.value = appliedSearch.value
|
||||||
|
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||||
|
draftSiteIds.value = [...appliedSiteIds.value]
|
||||||
|
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||||
|
filterDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCategory(code: string, selected: boolean): void {
|
||||||
|
draftCategoryCodes.value = selected
|
||||||
|
? [...draftCategoryCodes.value, code]
|
||||||
|
: draftCategoryCodes.value.filter(c => c !== code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSite(id: string, selected: boolean): void {
|
||||||
|
draftSiteIds.value = selected
|
||||||
|
? [...draftSiteIds.value, id]
|
||||||
|
: draftSiteIds.value.filter(s => s !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
|
||||||
|
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
|
||||||
|
* Les filtres vides sont omis pour une query propre.
|
||||||
|
*/
|
||||||
|
function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||||
|
const payload: Record<string, string | string[] | boolean> = {}
|
||||||
|
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||||
|
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||||
|
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||||
|
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
|
||||||
|
// page 1 via usePaginatedList) et ferme le drawer.
|
||||||
|
function applyFilters(): void {
|
||||||
|
appliedSearch.value = draftSearch.value.trim()
|
||||||
|
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||||
|
appliedSiteIds.value = [...draftSiteIds.value]
|
||||||
|
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||||
|
|
||||||
|
setFilters(buildFilterPayload(), { replace: true })
|
||||||
|
filterDrawerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||||
|
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||||
|
function resetFilters(): void {
|
||||||
|
draftSearch.value = ''
|
||||||
|
draftCategoryCodes.value = []
|
||||||
|
draftSiteIds.value = []
|
||||||
|
draftArchivedOnly.value = false
|
||||||
|
|
||||||
|
appliedSearch.value = ''
|
||||||
|
appliedCategoryCodes.value = []
|
||||||
|
appliedSiteIds.value = []
|
||||||
|
appliedArchivedOnly.value = false
|
||||||
|
|
||||||
|
setFilters({}, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge les referentiels du drawer (categories + sites) via ?pagination=false. */
|
||||||
|
async function loadFilterOptions(): Promise<void> {
|
||||||
|
const [cats, sites] = await Promise.all([
|
||||||
|
api.get<{ member?: Array<{ code: string, name: string }> }>(
|
||||||
|
'/categories',
|
||||||
|
{ pagination: 'false' },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
),
|
||||||
|
api.get<{ member?: Array<{ id: number, name: string }> }>(
|
||||||
|
'/sites',
|
||||||
|
{ pagination: 'false' },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
|
||||||
|
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||||
|
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
|
||||||
|
// l'utilisateur a accounting.view (gere cote back).
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
async function exportXlsx(): Promise<void> {
|
||||||
|
if (exporting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||||
|
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||||
|
// contenu faute d'overload blob sur le client partage — a generaliser via
|
||||||
|
// un ticket dedie si d'autres exports binaires arrivent.
|
||||||
|
const blob = await api.get<Blob>('/clients/export.xlsx', buildFilterPayload(), {
|
||||||
|
responseType: 'blob',
|
||||||
|
toast: false,
|
||||||
|
} as unknown as Parameters<typeof api.get>[2])
|
||||||
|
|
||||||
|
triggerDownload(blob, 'repertoire-clients.xlsx')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({
|
||||||
|
title: t('commercial.clients.toast.error'),
|
||||||
|
message: t('commercial.clients.toast.exportError'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||||
|
function triggerDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadClients()
|
||||||
|
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
|
||||||
|
// l'utilisateur perd juste les options de filtre.
|
||||||
|
loadFilterOptions().catch(() => {
|
||||||
|
categoryOptions.value = []
|
||||||
|
siteOptions.value = []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,892 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour vers le repertoire + titre. -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[32px] font-bold text-m-primary">{{ t('commercial.clients.form.title') }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
||||||
|
Sans validation de ce bloc, les onglets restent inaccessibles. Au
|
||||||
|
succes du POST, les champs passent en lecture seule et on bascule
|
||||||
|
automatiquement sur l'onglet Information. -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="main.companyName"
|
||||||
|
:label="t('commercial.clients.form.main.companyName')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="main.categoryIris"
|
||||||
|
:options="referentials.categories.value"
|
||||||
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:disabled="mainLocked"
|
||||||
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="main.relationType"
|
||||||
|
:options="relationOptions"
|
||||||
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
|
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||||
|
:disabled="mainLocked"
|
||||||
|
@update:model-value="onRelationChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="main.relationType === 'courtier'"
|
||||||
|
:model-value="main.brokerIri"
|
||||||
|
:options="referentials.brokers.value"
|
||||||
|
:label="t('commercial.clients.form.main.brokerName')"
|
||||||
|
:disabled="mainLocked"
|
||||||
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="main.relationType === 'distributeur'"
|
||||||
|
:model-value="main.distributorIri"
|
||||||
|
:options="referentials.distributors.value"
|
||||||
|
:label="t('commercial.clients.form.main.distributorName')"
|
||||||
|
:disabled="mainLocked"
|
||||||
|
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
v-model="main.triageService"
|
||||||
|
:label="t('commercial.clients.form.main.triageService')"
|
||||||
|
group-class="self-center"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.form.submit')"
|
||||||
|
:disabled="!isMainValid || mainSubmitting"
|
||||||
|
@click="submitMain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Onglets a validation incrementale ─────────────────────────────-->
|
||||||
|
<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)]">
|
||||||
|
<!-- pt-1 : aligne le bord superieur du textarea sur celui des
|
||||||
|
inputs (centres dans un conteneur h-12, soit ~4px de retrait haut). -->
|
||||||
|
<MalioInputTextArea
|
||||||
|
v-model="information.description"
|
||||||
|
:label="t('commercial.clients.form.information.description')"
|
||||||
|
resize="none"
|
||||||
|
group-class="row-span-2 pt-1"
|
||||||
|
text-input="h-full text-lg"
|
||||||
|
:disabled="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.competitors"
|
||||||
|
:label="t('commercial.clients.form.information.competitors')"
|
||||||
|
:readonly="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioDate
|
||||||
|
v-model="information.foundedAt"
|
||||||
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
|
:readonly="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.employeesCount"
|
||||||
|
:label="t('commercial.clients.form.information.employeesCount')"
|
||||||
|
:mask="EMPLOYEES_MASK"
|
||||||
|
:readonly="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="information.revenueAmount"
|
||||||
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||||
|
:disabled="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.directorName"
|
||||||
|
:label="t('commercial.clients.form.information.directorName')"
|
||||||
|
:readonly="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="information.profitAmount"
|
||||||
|
:label="t('commercial.clients.form.information.profitAmount')"
|
||||||
|
:disabled="isValidated('information')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
||||||
|
<!-- Desactive tant que le client n'est pas cree : evite un PATCH
|
||||||
|
avant le POST si l'utilisateur clique trop tot (le panneau
|
||||||
|
Information est l'onglet actif par defaut). -->
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.form.submit')"
|
||||||
|
:disabled="tabSubmitting || clientId === null"
|
||||||
|
@click="submitInformation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Contact -->
|
||||||
|
<template #contact>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ClientContactBlock
|
||||||
|
v-for="(contact, index) in contacts"
|
||||||
|
:key="index"
|
||||||
|
:model-value="contact"
|
||||||
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
|
:removable="index > 0"
|
||||||
|
:readonly="isValidated('contact')"
|
||||||
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
|
@remove="askRemoveContact(index)"
|
||||||
|
/>
|
||||||
|
<div v-if="!isValidated('contact')" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.contact.add')"
|
||||||
|
:disabled="!canAddContact"
|
||||||
|
@click="addContact"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.form.submit')"
|
||||||
|
:disabled="!canValidateContacts || tabSubmitting"
|
||||||
|
@click="submitContacts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Adresse -->
|
||||||
|
<template #address>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ClientAddressBlock
|
||||||
|
v-for="(address, index) in addresses"
|
||||||
|
:key="index"
|
||||||
|
:model-value="address"
|
||||||
|
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||||
|
:category-options="addressCategoryOptions"
|
||||||
|
:site-options="referentials.sites.value"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptions"
|
||||||
|
:removable="index > 0"
|
||||||
|
:readonly="isValidated('address')"
|
||||||
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
|
@remove="askRemoveAddress(index)"
|
||||||
|
@degraded="onAddressDegraded"
|
||||||
|
/>
|
||||||
|
<div v-if="!isValidated('address')" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.address.add')"
|
||||||
|
@click="addAddress"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.form.submit')"
|
||||||
|
:disabled="!canValidateAddresses || tabSubmitting"
|
||||||
|
@click="submitAddresses"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
|
||||||
|
<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">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.siren"
|
||||||
|
:label="t('commercial.clients.form.accounting.siren')"
|
||||||
|
:mask="SIREN_MASK"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.accountNumber"
|
||||||
|
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.tvaModeIri"
|
||||||
|
:options="referentials.tvaModes.value"
|
||||||
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@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"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.paymentDelayIri"
|
||||||
|
:options="referentials.paymentDelays.value"
|
||||||
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@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')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@update:model-value="onPaymentTypeChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="isBankRequired"
|
||||||
|
:model-value="accounting.bankIri"
|
||||||
|
:options="referentials.banks.value"
|
||||||
|
:label="t('commercial.clients.form.accounting.bank')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR. -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in ribs"
|
||||||
|
:key="index"
|
||||||
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
|
>
|
||||||
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="!accountingReadonly"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.label"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.bic"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.iban"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.accounting.addRib')"
|
||||||
|
@click="addRib"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.form.submit')"
|
||||||
|
:disabled="!canValidateAccounting || tabSubmitting"
|
||||||
|
@click="submitAccounting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet non encore implemente : frame vide, passage automatique.
|
||||||
|
Statistiques / Rapports / Echanges sont edit-only (absents a la
|
||||||
|
creation) — cf. buildClientFormTabKeys. -->
|
||||||
|
<template #transport><ComingSoonPlaceholder /></template>
|
||||||
|
</MalioTabList>
|
||||||
|
|
||||||
|
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
|
||||||
|
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<p>{{ confirmModal.message }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('commercial.clients.form.confirmDelete.cancel')"
|
||||||
|
@click="confirmModal.open = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('commercial.clients.form.confirmDelete.confirm')"
|
||||||
|
@click="runConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
|
import {
|
||||||
|
buildClientFormTabKeys,
|
||||||
|
CLIENT_FORM_PLACEHOLDER_TABS,
|
||||||
|
hasAtLeastOneValidContact,
|
||||||
|
isBankRequiredForPaymentType,
|
||||||
|
isBillingEmailRequired,
|
||||||
|
isContactNamed,
|
||||||
|
isRibRequiredForPaymentType,
|
||||||
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
|
import {
|
||||||
|
emptyAddress,
|
||||||
|
emptyContact,
|
||||||
|
emptyRib,
|
||||||
|
type AddressFormDraft,
|
||||||
|
type ContactFormDraft,
|
||||||
|
type RibFormDraft,
|
||||||
|
} from '~/modules/commercial/types/clientForm'
|
||||||
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
|
const SIREN_MASK = '#########'
|
||||||
|
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
|
||||||
|
const EMPLOYEES_MASK = '#######'
|
||||||
|
|
||||||
|
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
|
||||||
|
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const toast = useToast()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
/** Retour vers le repertoire clients (fleche d'en-tete). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/clients')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API.
|
||||||
|
* Retourne TOUJOURS une chaine (le composant de toast plante sur `undefined`) :
|
||||||
|
* le message de validation renvoye par le serveur (violations 422 / detail),
|
||||||
|
* sinon un libelle generique.
|
||||||
|
*/
|
||||||
|
function apiErrorMessage(error: unknown): string {
|
||||||
|
const data = (error as { data?: unknown })?.data
|
||||||
|
return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: t('commercial.clients.form.title') })
|
||||||
|
|
||||||
|
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
|
||||||
|
// seul) et Usine sont rediriges vers le repertoire (cf. §0 du ticket).
|
||||||
|
if (!can('commercial.clients.manage')) {
|
||||||
|
await navigateTo('/clients')
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccountingView = computed(() => can('commercial.clients.accounting.view'))
|
||||||
|
const canAccountingManage = computed(() => can('commercial.clients.accounting.manage'))
|
||||||
|
|
||||||
|
const referentials = useClientReferentials()
|
||||||
|
|
||||||
|
// ── Etat du client cree ────────────────────────────────────────────────────
|
||||||
|
const clientId = ref<number | null>(null)
|
||||||
|
const mainLocked = ref(false)
|
||||||
|
const mainSubmitting = ref(false)
|
||||||
|
const tabSubmitting = ref(false)
|
||||||
|
|
||||||
|
// ── Formulaire principal ────────────────────────────────────────────────────
|
||||||
|
const main = reactive({
|
||||||
|
companyName: null as string | null,
|
||||||
|
categoryIris: [] as string[],
|
||||||
|
relationType: null as 'distributeur' | 'courtier' | null,
|
||||||
|
distributorIri: null as string | null,
|
||||||
|
brokerIri: null as string | null,
|
||||||
|
triageService: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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') },
|
||||||
|
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
||||||
|
])
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
const isMainValid = computed(() => {
|
||||||
|
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
|
||||||
|
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
|
||||||
|
// distributeur/courtier » est choisi, le nom correspondant devient requis.
|
||||||
|
const relationValid
|
||||||
|
= main.relationType === null
|
||||||
|
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|
||||||
|
|| (main.relationType === 'courtier' && filled(main.brokerIri))
|
||||||
|
return filled(main.companyName)
|
||||||
|
&& main.categoryIris.length >= 1
|
||||||
|
&& relationValid
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onRelationChange(value: string | number | null): Promise<void> {
|
||||||
|
const relation = (value === null || value === '')
|
||||||
|
? null
|
||||||
|
: (String(value) as 'distributeur' | 'courtier')
|
||||||
|
main.relationType = relation
|
||||||
|
// Reinitialise la FK non concernee (une seule remplie a la fois, RG-1.03).
|
||||||
|
if (relation !== 'distributeur') main.distributorIri = null
|
||||||
|
if (relation !== 'courtier') main.brokerIri = null
|
||||||
|
|
||||||
|
if (relation === 'distributeur') await referentials.loadDistributors()
|
||||||
|
if (relation === 'courtier') await referentials.loadBrokers()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
|
||||||
|
async function submitMain(): Promise<void> {
|
||||||
|
if (!isMainValid.value || mainSubmitting.value) return
|
||||||
|
mainSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
companyName: main.companyName,
|
||||||
|
categories: main.categoryIris,
|
||||||
|
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
||||||
|
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
||||||
|
triageService: main.triageService,
|
||||||
|
}
|
||||||
|
const created = await api.post<ClientResponse>('/clients', payload, {
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
clientId.value = created.id
|
||||||
|
// Reaffiche la valeur normalisee renvoyee par le serveur.
|
||||||
|
main.companyName = created.companyName ?? main.companyName
|
||||||
|
|
||||||
|
mainLocked.value = true
|
||||||
|
unlockedIndex.value = 0
|
||||||
|
activeTab.value = 'information'
|
||||||
|
toast.success({ title: t('commercial.clients.toast.createSuccess') })
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// 409 = doublon nom de societe (RG d'unicite) → message explicite ;
|
||||||
|
// sinon on remonte le message de validation du serveur (ex: 422).
|
||||||
|
const status = (error as { response?: { status?: number } })?.response?.status
|
||||||
|
toast.error({
|
||||||
|
title: t('commercial.clients.toast.error'),
|
||||||
|
message: status === 409
|
||||||
|
? t('commercial.clients.form.duplicateCompany')
|
||||||
|
: apiErrorMessage(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
mainSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglets : ordre + gating progressif ─────────────────────────────────────
|
||||||
|
const activeTab = ref('information')
|
||||||
|
// Index du dernier onglet deverrouille (-1 tant que le client n'est pas cree).
|
||||||
|
const unlockedIndex = ref(-1)
|
||||||
|
// Onglets valides (passent en lecture seule).
|
||||||
|
const validated = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
|
||||||
|
|
||||||
|
// Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement.
|
||||||
|
const TAB_ICONS: Record<string, string> = {
|
||||||
|
information: 'mdi:account-outline',
|
||||||
|
contact: 'mdi:account-box-plus-outline',
|
||||||
|
address: 'mdi:map-marker-outline',
|
||||||
|
transport: 'mdi:truck-delivery-outline',
|
||||||
|
accounting: 'mdi:bank-circle-outline',
|
||||||
|
statistics: 'mdi:finance',
|
||||||
|
reports: 'mdi:file-document-edit-outline',
|
||||||
|
exchanges: 'mdi:account-group-outline',
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
||||||
|
key,
|
||||||
|
label: t(`commercial.clients.tab.${key}`),
|
||||||
|
icon: TAB_ICONS[key],
|
||||||
|
disabled: index > unlockedIndex.value,
|
||||||
|
})))
|
||||||
|
|
||||||
|
function isValidated(key: string): boolean {
|
||||||
|
return validated[key] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabIndex(key: string): number {
|
||||||
|
return tabKeys.value.indexOf(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */
|
||||||
|
function completeTab(key: string): void {
|
||||||
|
validated[key] = true
|
||||||
|
const next = tabKeys.value[tabIndex(key) + 1]
|
||||||
|
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
|
||||||
|
if (next) activeTab.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
|
||||||
|
watch(activeTab, (key) => {
|
||||||
|
if ((CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key)) {
|
||||||
|
const next = tabKeys.value[tabIndex(key) + 1]
|
||||||
|
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
|
||||||
|
if (next) activeTab.value = next
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Onglet Information ──────────────────────────────────────────────────────
|
||||||
|
const information = reactive({
|
||||||
|
description: null as string | null,
|
||||||
|
competitors: null as string | null,
|
||||||
|
foundedAt: null as string | null,
|
||||||
|
employeesCount: null as string | null,
|
||||||
|
revenueAmount: null as string | null,
|
||||||
|
profitAmount: null as string | null,
|
||||||
|
directorName: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
||||||
|
async function submitInformation(): Promise<void> {
|
||||||
|
if (clientId.value === null || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await api.patch(`/clients/${clientId.value}`, {
|
||||||
|
description: information.description || null,
|
||||||
|
competitors: information.competitors || null,
|
||||||
|
foundedAt: information.foundedAt || null,
|
||||||
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
|
revenueAmount: information.revenueAmount || null,
|
||||||
|
profitAmount: information.profitAmount || null,
|
||||||
|
directorName: information.directorName || null,
|
||||||
|
}, { toast: false })
|
||||||
|
completeTab('information')
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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()])
|
||||||
|
|
||||||
|
// « + 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]
|
||||||
|
return last !== undefined && isContactNamed(last)
|
||||||
|
})
|
||||||
|
|
||||||
|
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
|
||||||
|
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
|
||||||
|
|
||||||
|
function addContact(): void {
|
||||||
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveContact(index: number): void {
|
||||||
|
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
||||||
|
contacts.value.splice(index, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
|
||||||
|
async function submitContacts(): Promise<void> {
|
||||||
|
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
for (const contact of contacts.value) {
|
||||||
|
// On ignore les blocs totalement vides (ni nom ni prenom).
|
||||||
|
if (!isContactNamed(contact)) continue
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
firstName: contact.firstName || null,
|
||||||
|
lastName: contact.lastName || null,
|
||||||
|
jobTitle: contact.jobTitle || null,
|
||||||
|
phonePrimary: contact.phonePrimary || null,
|
||||||
|
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||||
|
email: contact.email || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact.id === null) {
|
||||||
|
const created = await api.post<ContactResponse>(
|
||||||
|
`/clients/${clientId.value}/contacts`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
contact.id = created.id
|
||||||
|
contact.iri = created['@id'] ?? null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Adresse ──────────────────────────────────────────────────────────
|
||||||
|
const addresses = ref<AddressFormDraft[]>([emptyAddress()])
|
||||||
|
const addressDegradedNotified = ref(false)
|
||||||
|
|
||||||
|
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
||||||
|
const addressCategoryOptions = computed(() =>
|
||||||
|
referentials.categories.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
|
||||||
|
const contactOptions = computed<RefOption[]>(() =>
|
||||||
|
contacts.value
|
||||||
|
.filter(c => c.iri !== null)
|
||||||
|
.map(c => ({
|
||||||
|
value: c.iri as string,
|
||||||
|
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
|
||||||
|
const countryOptions: RefOption[] = [
|
||||||
|
{ value: 'France', label: 'France' },
|
||||||
|
{ value: 'Espagne', label: 'Espagne' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// RG-1.10 (>= 1 site) + RG-1.11 (email facturation si Facturation) sur chaque adresse.
|
||||||
|
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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function addAddress(): void {
|
||||||
|
addresses.value.push(emptyAddress())
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveAddress(index: number): void {
|
||||||
|
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
||||||
|
addresses.value.splice(index, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade. */
|
||||||
|
function onAddressDegraded(): void {
|
||||||
|
if (addressDegradedNotified.value) return
|
||||||
|
addressDegradedNotified.value = true
|
||||||
|
toast.warning({
|
||||||
|
title: t('commercial.clients.toast.error'),
|
||||||
|
message: t('commercial.clients.form.address.degraded'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
|
||||||
|
async function submitAddresses(): Promise<void> {
|
||||||
|
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
for (const address of addresses.value) {
|
||||||
|
const body = {
|
||||||
|
isProspect: address.isProspect,
|
||||||
|
isDelivery: address.isDelivery,
|
||||||
|
isBilling: address.isBilling,
|
||||||
|
country: address.country,
|
||||||
|
postalCode: address.postalCode || null,
|
||||||
|
city: address.city || null,
|
||||||
|
street: address.street || null,
|
||||||
|
streetComplement: address.streetComplement || null,
|
||||||
|
categories: address.categoryIris,
|
||||||
|
sites: address.siteIris,
|
||||||
|
contacts: address.contactIris,
|
||||||
|
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address.id === null) {
|
||||||
|
const created = await api.post<{ id: number }>(
|
||||||
|
`/clients/${clientId.value}/addresses`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
address.id = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Comptabilite ─────────────────────────────────────────────────────
|
||||||
|
const accounting = reactive({
|
||||||
|
siren: null as string | null,
|
||||||
|
accountNumber: null as string | null,
|
||||||
|
tvaModeIri: null as string | null,
|
||||||
|
nTva: null as string | null,
|
||||||
|
paymentDelayIri: null as string | null,
|
||||||
|
paymentTypeIri: null as string | null,
|
||||||
|
bankIri: null as string | null,
|
||||||
|
})
|
||||||
|
const ribs = ref<RibFormDraft[]>([])
|
||||||
|
|
||||||
|
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
|
||||||
|
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
|
||||||
|
|
||||||
|
// Code du type de reglement selectionne (pour RG-1.12 / RG-1.13).
|
||||||
|
const selectedPaymentTypeCode = computed(() =>
|
||||||
|
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
||||||
|
)
|
||||||
|
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||||
|
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||||
|
|
||||||
|
function onPaymentTypeChange(value: string | number | null): void {
|
||||||
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
|
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
|
||||||
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ribIsComplete(rib: RibFormDraft): boolean {
|
||||||
|
const filled = (v: string | null) => v !== null && v.trim() !== ''
|
||||||
|
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
|
||||||
|
const canValidateAccounting = computed(() => {
|
||||||
|
if (isBankRequired.value && (accounting.bankIri === null)) return false
|
||||||
|
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
function addRib(): void {
|
||||||
|
ribs.value.push(emptyRib())
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveRib(index: number): void {
|
||||||
|
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
||||||
|
ribs.value.splice(index, 1)
|
||||||
|
// Garde au moins un bloc RIB visible (cf. amorce au montage).
|
||||||
|
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting)
|
||||||
|
* PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict
|
||||||
|
* RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back).
|
||||||
|
*/
|
||||||
|
async function submitAccounting(): Promise<void> {
|
||||||
|
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await api.patch(`/clients/${clientId.value}`, {
|
||||||
|
siren: accounting.siren || null,
|
||||||
|
accountNumber: accounting.accountNumber || null,
|
||||||
|
tvaMode: accounting.tvaModeIri,
|
||||||
|
nTva: accounting.nTva || null,
|
||||||
|
paymentDelay: accounting.paymentDelayIri,
|
||||||
|
paymentType: accounting.paymentTypeIri,
|
||||||
|
bank: isBankRequired.value ? accounting.bankIri : null,
|
||||||
|
}, { toast: false })
|
||||||
|
|
||||||
|
for (const rib of ribs.value) {
|
||||||
|
if (!ribIsComplete(rib)) continue
|
||||||
|
if (rib.id === null) {
|
||||||
|
const created = await api.post<{ id: number }>(
|
||||||
|
`/clients/${clientId.value}/ribs`,
|
||||||
|
{ label: rib.label, bic: rib.bic, iban: rib.iban },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
rib.id = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal de confirmation generique ─────────────────────────────────────────
|
||||||
|
const confirmModal = reactive({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
action: null as null | (() => void),
|
||||||
|
})
|
||||||
|
|
||||||
|
function askConfirm(message: string, action: () => void): void {
|
||||||
|
confirmModal.message = message
|
||||||
|
confirmModal.action = action
|
||||||
|
confirmModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function runConfirm(): void {
|
||||||
|
confirmModal.action?.()
|
||||||
|
confirmModal.action = null
|
||||||
|
confirmModal.open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types de reponse API ────────────────────────────────────────────────────
|
||||||
|
interface ClientResponse {
|
||||||
|
id: number
|
||||||
|
companyName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactResponse {
|
||||||
|
'@id'?: string
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('commercial.title') }}</h1>
|
<PageHeader>{{ $t('commercial.title') }}</PageHeader>
|
||||||
<p class="mt-4 text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
<p class="text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Types « brouillon » de l'ecran « Ajouter un client » (M1 Commercial).
|
||||||
|
*
|
||||||
|
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
|
||||||
|
* DTO de l'API : elles portent en plus des champs purement UI (`hasSecondaryPhone`)
|
||||||
|
* et l'`iri` Hydra des entites creees (necessaire pour rattacher une adresse a
|
||||||
|
* des contacts deja persistes, M2M). Partage par la page et les blocs reutilisables
|
||||||
|
* `ClientContactBlock` / `ClientAddressBlock` (reutilises par 1.11/1.12).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Un contact du client (onglet Contact). */
|
||||||
|
export interface ContactFormDraft {
|
||||||
|
/** Id serveur une fois le contact cree (null tant que non persiste). */
|
||||||
|
id: number | null
|
||||||
|
/** IRI Hydra du contact cree — utilise pour le rattachement M2M cote adresse. */
|
||||||
|
iri: string | null
|
||||||
|
firstName: string | null
|
||||||
|
lastName: string | null
|
||||||
|
jobTitle: string | null
|
||||||
|
phonePrimary: string | null
|
||||||
|
phoneSecondary: string | null
|
||||||
|
email: string | null
|
||||||
|
/** UI : le 2e numero a ete revele via le bouton « + ». */
|
||||||
|
hasSecondaryPhone: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une adresse du client (onglet Adresse). */
|
||||||
|
export interface AddressFormDraft {
|
||||||
|
id: number | null
|
||||||
|
isProspect: boolean
|
||||||
|
isDelivery: boolean
|
||||||
|
isBilling: boolean
|
||||||
|
country: string
|
||||||
|
postalCode: string | null
|
||||||
|
city: string | null
|
||||||
|
street: string | null
|
||||||
|
streetComplement: string | null
|
||||||
|
/** IRI des categories rattachees (hors DISTRIBUTEUR/COURTIER — RG-1.29). */
|
||||||
|
categoryIris: string[]
|
||||||
|
/** IRI des sites Starseed rattaches (>= 1 obligatoire — RG-1.10). */
|
||||||
|
siteIris: string[]
|
||||||
|
/** IRI des contacts rattaches (= blocs Contact deja crees). */
|
||||||
|
contactIris: string[]
|
||||||
|
/** Email de facturation (obligatoire si isBilling — RG-1.11). */
|
||||||
|
billingEmail: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un RIB du client (onglet Comptabilite). */
|
||||||
|
export interface RibFormDraft {
|
||||||
|
id: number | null
|
||||||
|
label: string | null
|
||||||
|
bic: string | null
|
||||||
|
iban: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un contact vierge. */
|
||||||
|
export function emptyContact(): ContactFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
iri: null,
|
||||||
|
firstName: null,
|
||||||
|
lastName: null,
|
||||||
|
jobTitle: null,
|
||||||
|
phonePrimary: null,
|
||||||
|
phoneSecondary: null,
|
||||||
|
email: null,
|
||||||
|
hasSecondaryPhone: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique une adresse vierge (pays prerempli « France »). */
|
||||||
|
export function emptyAddress(): AddressFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
isProspect: false,
|
||||||
|
isDelivery: false,
|
||||||
|
isBilling: false,
|
||||||
|
country: 'France',
|
||||||
|
postalCode: null,
|
||||||
|
city: null,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
categoryIris: [],
|
||||||
|
siteIris: [],
|
||||||
|
contactIris: [],
|
||||||
|
billingEmail: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un RIB vierge. */
|
||||||
|
export function emptyRib(): RibFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
label: null,
|
||||||
|
bic: null,
|
||||||
|
iban: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
canEditClient,
|
||||||
|
categoryOptionsOf,
|
||||||
|
contactOptionsOf,
|
||||||
|
iriOf,
|
||||||
|
mapAccountingDraft,
|
||||||
|
mapAddressToDraft,
|
||||||
|
mapAddressView,
|
||||||
|
mapContactToDraft,
|
||||||
|
mapRibToDraft,
|
||||||
|
referentialOptionOf,
|
||||||
|
relationOf,
|
||||||
|
showArchiveAction,
|
||||||
|
showRestoreAction,
|
||||||
|
siteOptionsOf,
|
||||||
|
type ClientDetail,
|
||||||
|
} from '../clientConsultation'
|
||||||
|
|
||||||
|
describe('iriOf', () => {
|
||||||
|
it('retourne l\'@id d\'une relation embarquee (objet)', () => {
|
||||||
|
expect(iriOf({ '@id': '/api/payment_types/10', code: 'LCR' })).toBe('/api/payment_types/10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne la chaine telle quelle si la relation est deja un IRI', () => {
|
||||||
|
expect(iriOf('/api/banks/3')).toBe('/api/banks/3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => {
|
||||||
|
expect(iriOf(null)).toBeNull()
|
||||||
|
expect(iriOf(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('relationOf', () => {
|
||||||
|
it('detecte une relation distributeur et expose son nom', () => {
|
||||||
|
const client = { distributor: { '@id': '/api/clients/15', companyName: 'DISTRIB GRAND SUD-OUEST' } } as ClientDetail
|
||||||
|
expect(relationOf(client)).toEqual({ type: 'distributeur', name: 'DISTRIB GRAND SUD-OUEST' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detecte une relation courtier et expose son nom', () => {
|
||||||
|
const client = { broker: { '@id': '/api/clients/16', companyName: 'CABINET LEONARD' } } as ClientDetail
|
||||||
|
expect(relationOf(client)).toEqual({ type: 'courtier', name: 'CABINET LEONARD' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne type null quand aucune relation n\'est posee (cles omises)', () => {
|
||||||
|
expect(relationOf({} as ClientDetail)).toEqual({ type: null, name: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapContactToDraft', () => {
|
||||||
|
it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => {
|
||||||
|
const draft = mapContactToDraft({
|
||||||
|
'@id': '/api/client_contacts/18',
|
||||||
|
id: 18,
|
||||||
|
firstName: 'Sophie',
|
||||||
|
lastName: 'Léonard',
|
||||||
|
jobTitle: 'Gérante',
|
||||||
|
phonePrimary: '0549112233',
|
||||||
|
email: 'sophie@x.fr',
|
||||||
|
})
|
||||||
|
expect(draft.id).toBe(18)
|
||||||
|
expect(draft.iri).toBe('/api/client_contacts/18')
|
||||||
|
expect(draft.phonePrimary).toBe('05 49 11 22 33')
|
||||||
|
expect(draft.hasSecondaryPhone).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revele le 2e telephone quand phoneSecondary est present', () => {
|
||||||
|
const draft = mapContactToDraft({
|
||||||
|
'@id': '/api/client_contacts/19',
|
||||||
|
id: 19,
|
||||||
|
phonePrimary: '0600000000',
|
||||||
|
phoneSecondary: '0611111111',
|
||||||
|
})
|
||||||
|
expect(draft.hasSecondaryPhone).toBe(true)
|
||||||
|
expect(draft.phoneSecondary).toBe('06 11 11 11 11')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapAddressToDraft', () => {
|
||||||
|
it('extrait les iris de sites / categories / contacts (objets ou chaines)', () => {
|
||||||
|
const draft = mapAddressToDraft({
|
||||||
|
'@id': '/api/client_addresses/18',
|
||||||
|
id: 18,
|
||||||
|
country: 'France',
|
||||||
|
postalCode: '86100',
|
||||||
|
city: 'Châtellerault',
|
||||||
|
street: '5 rue des Courtiers',
|
||||||
|
billingEmail: 'factures@x.fr',
|
||||||
|
isProspect: false,
|
||||||
|
isDelivery: false,
|
||||||
|
isBilling: true,
|
||||||
|
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#056CF2' }],
|
||||||
|
categories: [{ '@id': '/api/categories/3', code: 'SECTEUR' }],
|
||||||
|
contacts: [{ '@id': '/api/client_contacts/18' }, '/api/client_contacts/20'],
|
||||||
|
})
|
||||||
|
expect(draft.siteIris).toEqual(['/api/sites/4'])
|
||||||
|
expect(draft.categoryIris).toEqual(['/api/categories/3'])
|
||||||
|
expect(draft.contactIris).toEqual(['/api/client_contacts/18', '/api/client_contacts/20'])
|
||||||
|
expect(draft.isBilling).toBe(true)
|
||||||
|
expect(draft.city).toBe('Châtellerault')
|
||||||
|
expect(draft.country).toBe('France')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolere les sous-collections absentes (defaut tableau vide, pays France)', () => {
|
||||||
|
const draft = mapAddressToDraft({ '@id': '/api/client_addresses/9', id: 9 })
|
||||||
|
expect(draft.siteIris).toEqual([])
|
||||||
|
expect(draft.categoryIris).toEqual([])
|
||||||
|
expect(draft.contactIris).toEqual([])
|
||||||
|
expect(draft.country).toBe('France')
|
||||||
|
expect(draft.isBilling).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapRibToDraft', () => {
|
||||||
|
it('mappe label / bic / iban et l\'id serveur', () => {
|
||||||
|
const draft = mapRibToDraft({ '@id': '/api/client_ribs/3', id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
|
||||||
|
expect(draft).toEqual({ id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapAccountingDraft', () => {
|
||||||
|
it('mappe les scalaires et resout les iris des referentiels embarques', () => {
|
||||||
|
const acc = mapAccountingDraft({
|
||||||
|
'@id': '/api/clients/1',
|
||||||
|
id: 1,
|
||||||
|
siren: '123456789',
|
||||||
|
accountNumber: '411000',
|
||||||
|
nTva: 'FR123',
|
||||||
|
tvaMode: { '@id': '/api/tva_modes/1' },
|
||||||
|
paymentDelay: { '@id': '/api/payment_delays/2' },
|
||||||
|
paymentType: { '@id': '/api/payment_types/10', code: 'LCR' },
|
||||||
|
bank: { '@id': '/api/banks/3' },
|
||||||
|
} as ClientDetail)
|
||||||
|
expect(acc).toEqual({
|
||||||
|
siren: '123456789',
|
||||||
|
accountNumber: '411000',
|
||||||
|
nTva: 'FR123',
|
||||||
|
tvaModeIri: '/api/tva_modes/1',
|
||||||
|
paymentDelayIri: '/api/payment_delays/2',
|
||||||
|
paymentTypeIri: '/api/payment_types/10',
|
||||||
|
bankIri: '/api/banks/3',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renvoie des null quand les champs comptables sont absents (sans accounting.view)', () => {
|
||||||
|
const acc = mapAccountingDraft({} as ClientDetail)
|
||||||
|
expect(acc).toEqual({
|
||||||
|
siren: null,
|
||||||
|
accountNumber: null,
|
||||||
|
nTva: null,
|
||||||
|
tvaModeIri: null,
|
||||||
|
paymentDelayIri: null,
|
||||||
|
paymentTypeIri: null,
|
||||||
|
bankIri: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('options construites depuis l\'embed (role-independantes)', () => {
|
||||||
|
it('categoryOptionsOf expose value=IRI, label=nom, code', () => {
|
||||||
|
expect(categoryOptionsOf([{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }])).toEqual([
|
||||||
|
{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('siteOptionsOf expose value=IRI, label=nom', () => {
|
||||||
|
expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([
|
||||||
|
{ value: '/api/sites/4', label: 'Chatellerault' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => {
|
||||||
|
expect(contactOptionsOf([
|
||||||
|
{ '@id': '/api/client_contacts/1', id: 1, firstName: 'Jean', lastName: 'Dupont' },
|
||||||
|
{ '@id': '/api/client_contacts/2', id: 2, email: 'a@b.fr' },
|
||||||
|
])).toEqual([
|
||||||
|
{ value: '/api/client_contacts/1', label: 'Jean Dupont' },
|
||||||
|
{ value: '/api/client_contacts/2', label: 'a@b.fr' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => {
|
||||||
|
expect(referentialOptionOf({ '@id': '/api/payment_types/10', label: 'LCR' })).toEqual([
|
||||||
|
{ value: '/api/payment_types/10', label: 'LCR' },
|
||||||
|
])
|
||||||
|
expect(referentialOptionOf('/api/banks/3')).toEqual([])
|
||||||
|
expect(referentialOptionOf(null)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mapAddressView assemble brouillon + options propres a l\'adresse', () => {
|
||||||
|
const view = mapAddressView({
|
||||||
|
'@id': '/api/client_addresses/18',
|
||||||
|
id: 18,
|
||||||
|
city: 'Châtellerault',
|
||||||
|
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault' }],
|
||||||
|
categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }],
|
||||||
|
})
|
||||||
|
expect(view.draft.id).toBe(18)
|
||||||
|
expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault' }])
|
||||||
|
expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('canEditClient', () => {
|
||||||
|
const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
|
||||||
|
|
||||||
|
it('visible pour manage', () => {
|
||||||
|
expect(canEditClient(can(['commercial.clients.manage']))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('visible pour accounting.manage (role Compta)', () => {
|
||||||
|
expect(canEditClient(can(['commercial.clients.accounting.manage']))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque sans aucune des deux permissions (role Usine)', () => {
|
||||||
|
expect(canEditClient(can(['commercial.clients.view']))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('showArchiveAction / showRestoreAction', () => {
|
||||||
|
const can = (granted: string[]) => (code: string) => granted.includes(code)
|
||||||
|
|
||||||
|
it('Archiver : visible avec la permission archive ET client non archive', () => {
|
||||||
|
expect(showArchiveAction(can(['commercial.clients.archive']), false)).toBe(true)
|
||||||
|
expect(showArchiveAction(can(['commercial.clients.archive']), true)).toBe(false)
|
||||||
|
expect(showArchiveAction(can([]), false)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Restaurer : visible avec la permission archive ET client archive', () => {
|
||||||
|
expect(showRestoreAction(can(['commercial.clients.archive']), true)).toBe(true)
|
||||||
|
expect(showRestoreAction(can(['commercial.clients.archive']), false)).toBe(false)
|
||||||
|
expect(showRestoreAction(can([]), true)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildAccountingPayload,
|
||||||
|
buildAddressPayload,
|
||||||
|
buildContactPayload,
|
||||||
|
buildInformationPayload,
|
||||||
|
buildMainPayload,
|
||||||
|
buildRibPayload,
|
||||||
|
mapAccountingFormDraft,
|
||||||
|
mapInformationDraft,
|
||||||
|
mapMainDraft,
|
||||||
|
resolveTabEditability,
|
||||||
|
type AccountingFormDraft,
|
||||||
|
type InformationFormDraft,
|
||||||
|
type MainFormDraft,
|
||||||
|
} from '../clientEdit'
|
||||||
|
import type { ClientDetail } from '../clientConsultation'
|
||||||
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
|
// ── Fabriques de brouillons (valeurs distinctes pour reperer les fuites) ─────
|
||||||
|
|
||||||
|
function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft {
|
||||||
|
return {
|
||||||
|
companyName: 'ACME',
|
||||||
|
categoryIris: ['/api/categories/1'],
|
||||||
|
relationType: null,
|
||||||
|
distributorIri: null,
|
||||||
|
brokerIri: null,
|
||||||
|
triageService: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function informationDraft(overrides: Partial<InformationFormDraft> = {}): InformationFormDraft {
|
||||||
|
return {
|
||||||
|
description: 'desc',
|
||||||
|
competitors: 'concurrents',
|
||||||
|
foundedAt: '2010-05-01',
|
||||||
|
employeesCount: '42',
|
||||||
|
revenueAmount: '1000000',
|
||||||
|
profitAmount: '50000',
|
||||||
|
directorName: 'PDG',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): AccountingFormDraft {
|
||||||
|
return {
|
||||||
|
siren: '123456789',
|
||||||
|
accountNumber: 'C-001',
|
||||||
|
nTva: 'FR123',
|
||||||
|
tvaModeIri: '/api/tva_modes/1',
|
||||||
|
paymentDelayIri: '/api/payment_delays/1',
|
||||||
|
paymentTypeIri: '/api/payment_types/1',
|
||||||
|
bankIri: '/api/banks/1',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
]
|
||||||
|
const INFORMATION_KEYS = [
|
||||||
|
'description', 'competitors', 'foundedAt', 'employeesCount',
|
||||||
|
'revenueAmount', 'profitAmount', 'directorName',
|
||||||
|
]
|
||||||
|
const ACCOUNTING_KEYS = ['siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', 'bank']
|
||||||
|
|
||||||
|
describe('buildMainPayload — scoping strict groupe client:write:main', () => {
|
||||||
|
it('n\'expose QUE les champs du groupe main (aucune fuite information/accounting)', () => {
|
||||||
|
expect(Object.keys(buildMainPayload(mainDraft())).sort()).toEqual([...MAIN_KEYS].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('relation distributeur : renseigne distributor, force broker a null (RG-1.03)', () => {
|
||||||
|
const payload = buildMainPayload(mainDraft({
|
||||||
|
relationType: 'distributeur',
|
||||||
|
distributorIri: '/api/clients/9',
|
||||||
|
brokerIri: '/api/clients/7',
|
||||||
|
}))
|
||||||
|
expect(payload.distributor).toBe('/api/clients/9')
|
||||||
|
expect(payload.broker).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('relation courtier : renseigne broker, force distributor a null (RG-1.03)', () => {
|
||||||
|
const payload = buildMainPayload(mainDraft({
|
||||||
|
relationType: 'courtier',
|
||||||
|
distributorIri: '/api/clients/9',
|
||||||
|
brokerIri: '/api/clients/7',
|
||||||
|
}))
|
||||||
|
expect(payload.broker).toBe('/api/clients/7')
|
||||||
|
expect(payload.distributor).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sans relation : distributor et broker a null', () => {
|
||||||
|
const payload = buildMainPayload(mainDraft({ relationType: null }))
|
||||||
|
expect(payload.distributor).toBeNull()
|
||||||
|
expect(payload.broker).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
|
||||||
|
it('n\'expose QUE les champs du groupe information (aucune fuite main/accounting)', () => {
|
||||||
|
expect(Object.keys(buildInformationPayload(informationDraft())).sort()).toEqual([...INFORMATION_KEYS].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('convertit employeesCount en nombre et vide -> null', () => {
|
||||||
|
expect(buildInformationPayload(informationDraft({ employeesCount: '42' })).employeesCount).toBe(42)
|
||||||
|
expect(buildInformationPayload(informationDraft({ employeesCount: null })).employeesCount).toBeNull()
|
||||||
|
expect(buildInformationPayload(informationDraft({ employeesCount: '' })).employeesCount).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('chaines vides normalisees en null', () => {
|
||||||
|
const payload = buildInformationPayload(informationDraft({ description: '', directorName: '' }))
|
||||||
|
expect(payload.description).toBeNull()
|
||||||
|
expect(payload.directorName).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
||||||
|
it('n\'expose QUE les champs du groupe accounting (aucune fuite main/information)', () => {
|
||||||
|
expect(Object.keys(buildAccountingPayload(accountingDraft(), true)).sort()).toEqual([...ACCOUNTING_KEYS].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('banque conservee si requise (Virement), forcee a null sinon (RG-1.12)', () => {
|
||||||
|
expect(buildAccountingPayload(accountingDraft(), true).bank).toBe('/api/banks/1')
|
||||||
|
expect(buildAccountingPayload(accountingDraft(), false).bank).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
|
||||||
|
it('contact : telephone secondaire ignore si non revele', () => {
|
||||||
|
const contact: ContactFormDraft = {
|
||||||
|
id: 5, iri: '/api/client_contacts/5', firstName: 'A', lastName: 'B',
|
||||||
|
jobTitle: null, phonePrimary: '0549112233', phoneSecondary: '0600000000',
|
||||||
|
email: null, hasSecondaryPhone: false,
|
||||||
|
}
|
||||||
|
expect(buildContactPayload(contact).phoneSecondary).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
|
||||||
|
const address: AddressFormDraft = {
|
||||||
|
id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France',
|
||||||
|
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
|
||||||
|
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
|
||||||
|
billingEmail: 'facturation@acme.fr',
|
||||||
|
}
|
||||||
|
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
|
||||||
|
expect(buildAddressPayload(address, false).billingEmail).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rib : label / bic / iban transmis tels quels', () => {
|
||||||
|
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
|
||||||
|
expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapMainDraft — pre-remplissage bloc principal', () => {
|
||||||
|
it('resout la relation et extrait les IRI (sans contact inline)', () => {
|
||||||
|
const client = {
|
||||||
|
'@id': '/api/clients/1', id: 1,
|
||||||
|
companyName: 'ACME', 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.categoryIris).toEqual(['/api/categories/1'])
|
||||||
|
expect(draft.relationType).toBe('distributeur')
|
||||||
|
expect(draft.distributorIri).toBe('/api/clients/9')
|
||||||
|
expect(draft.brokerIri).toBeNull()
|
||||||
|
expect(draft.triageService).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
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.categoryIris).toEqual([])
|
||||||
|
expect(draft.relationType).toBeNull()
|
||||||
|
expect(draft.triageService).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapInformationDraft — pre-remplissage onglet Information', () => {
|
||||||
|
it('tronque foundedAt en YYYY-MM-DD et stringifie employeesCount', () => {
|
||||||
|
const draft = mapInformationDraft({
|
||||||
|
'@id': '/api/clients/1', id: 1,
|
||||||
|
foundedAt: '2010-05-01T00:00:00+00:00', employeesCount: 42, revenueAmount: '1000000',
|
||||||
|
} as ClientDetail)
|
||||||
|
expect(draft.foundedAt).toBe('2010-05-01')
|
||||||
|
expect(draft.employeesCount).toBe('42')
|
||||||
|
expect(draft.revenueAmount).toBe('1000000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cles omises -> null', () => {
|
||||||
|
const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1 } as ClientDetail)
|
||||||
|
expect(draft.foundedAt).toBeNull()
|
||||||
|
expect(draft.employeesCount).toBeNull()
|
||||||
|
expect(draft.description).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
|
||||||
|
it('extrait les scalaires et les IRI des referentiels embarques', () => {
|
||||||
|
const draft = mapAccountingFormDraft({
|
||||||
|
'@id': '/api/clients/1', id: 1,
|
||||||
|
siren: '123456789', accountNumber: 'C-001', nTva: 'FR123',
|
||||||
|
tvaMode: { '@id': '/api/tva_modes/2', label: 'Normal' },
|
||||||
|
paymentType: '/api/payment_types/3',
|
||||||
|
} as ClientDetail)
|
||||||
|
expect(draft.siren).toBe('123456789')
|
||||||
|
expect(draft.tvaModeIri).toBe('/api/tva_modes/2')
|
||||||
|
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
|
||||||
|
expect(draft.bankIri).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
|
||||||
|
it('Admin : tout editable', () => {
|
||||||
|
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
|
||||||
|
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
|
||||||
|
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
|
||||||
|
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
|
||||||
|
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
|
||||||
|
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Sans permission d\'edition : rien d\'editable', () => {
|
||||||
|
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
|
||||||
|
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
applyProspectExclusivity,
|
||||||
|
buildClientFormTabKeys,
|
||||||
|
canSelectDeliveryOrBilling,
|
||||||
|
canSelectProspect,
|
||||||
|
hasAtLeastOneValidContact,
|
||||||
|
isBankRequiredForPaymentType,
|
||||||
|
isBillingEmailRequired,
|
||||||
|
isContactNamed,
|
||||||
|
isRibRequiredForPaymentType,
|
||||||
|
type ContactDraft,
|
||||||
|
} from '../clientFormRules'
|
||||||
|
|
||||||
|
describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => {
|
||||||
|
it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
|
||||||
|
expect(buildClientFormTabKeys(true)).toContain('accounting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exclut l onglet accounting sinon (Bureau / Commerciale)', () => {
|
||||||
|
expect(buildClientFormTabKeys(false)).not.toContain('accounting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a la creation, exclut Statistiques / Rapports / Echanges', () => {
|
||||||
|
const keys = buildClientFormTabKeys(true)
|
||||||
|
expect(keys).toEqual(['information', 'contact', 'address', 'transport', 'accounting'])
|
||||||
|
expect(keys).not.toContain('statistics')
|
||||||
|
expect(keys).not.toContain('reports')
|
||||||
|
expect(keys).not.toContain('exchanges')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('en modification (includeEditOnlyTabs), ajoute les onglets edit-only en fin', () => {
|
||||||
|
const keys = buildClientFormTabKeys(true, { includeEditOnlyTabs: true })
|
||||||
|
expect(keys).toEqual([
|
||||||
|
'information',
|
||||||
|
'contact',
|
||||||
|
'address',
|
||||||
|
'transport',
|
||||||
|
'accounting',
|
||||||
|
'statistics',
|
||||||
|
'reports',
|
||||||
|
'exchanges',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isContactNamed (RG-1.05)', () => {
|
||||||
|
it('vrai si le prenom est renseigne', () => {
|
||||||
|
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai si le nom est renseigne', () => {
|
||||||
|
expect(isContactNamed({ firstName: null, lastName: 'Martin' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux si les deux sont vides ou espaces uniquement', () => {
|
||||||
|
expect(isContactNamed({ firstName: null, lastName: null })).toBe(false)
|
||||||
|
expect(isContactNamed({ firstName: ' ', lastName: '' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasAtLeastOneValidContact (RG-1.14)', () => {
|
||||||
|
it('faux sur une liste vide', () => {
|
||||||
|
expect(hasAtLeastOneValidContact([])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux si aucun contact n a de nom ni prenom', () => {
|
||||||
|
const contacts: ContactDraft[] = [
|
||||||
|
{ firstName: null, lastName: null },
|
||||||
|
{ firstName: '', lastName: ' ' },
|
||||||
|
]
|
||||||
|
expect(hasAtLeastOneValidContact(contacts)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai des qu un contact a un nom ou un prenom', () => {
|
||||||
|
const contacts: ContactDraft[] = [
|
||||||
|
{ firstName: null, lastName: null },
|
||||||
|
{ firstName: 'Bob', lastName: null },
|
||||||
|
]
|
||||||
|
expect(hasAtLeastOneValidContact(contacts)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
|
||||||
|
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
|
||||||
|
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
|
||||||
|
expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
|
||||||
|
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
|
||||||
|
expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
|
||||||
|
expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cocher Prospect efface Livraison et Facturation', () => {
|
||||||
|
const next = applyProspectExclusivity(
|
||||||
|
{ isProspect: false, isDelivery: true, isBilling: true },
|
||||||
|
'isProspect',
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cocher Livraison efface Prospect', () => {
|
||||||
|
const next = applyProspectExclusivity(
|
||||||
|
{ isProspect: true, isDelivery: false, isBilling: false },
|
||||||
|
'isDelivery',
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cocher Facturation efface Prospect mais conserve Livraison', () => {
|
||||||
|
const next = applyProspectExclusivity(
|
||||||
|
{ isProspect: true, isDelivery: true, isBilling: false },
|
||||||
|
'isBilling',
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decocher un drapeau ne reactive rien d autre', () => {
|
||||||
|
const next = applyProspectExclusivity(
|
||||||
|
{ isProspect: false, isDelivery: true, isBilling: true },
|
||||||
|
'isBilling',
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isBillingEmailRequired (RG-1.11)', () => {
|
||||||
|
it('obligatoire uniquement si Facturation est coche', () => {
|
||||||
|
expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true)
|
||||||
|
expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
|
||||||
|
it('banque obligatoire si VIREMENT', () => {
|
||||||
|
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
|
||||||
|
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
|
||||||
|
expect(isBankRequiredForPaymentType(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RIB obligatoire si LCR', () => {
|
||||||
|
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
|
||||||
|
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
|
||||||
|
expect(isRibRequiredForPaymentType(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/**
|
||||||
|
* Helpers purs de l'ecran « Consultation client » (M1 Commercial, lecture seule).
|
||||||
|
*
|
||||||
|
* Mappent le payload `GET /api/clients/{id}` (relations embarquees, cf. groupe
|
||||||
|
* `client:item:read` + `client:read:accounting`) vers les brouillons « plats »
|
||||||
|
* partages avec les blocs reutilisables `ClientContactBlock` / `ClientAddressBlock`
|
||||||
|
* et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables
|
||||||
|
* unitairement (cf. clientConsultation.spec.ts).
|
||||||
|
*
|
||||||
|
* Rappels de contrat back (verifies sur l'API reelle) :
|
||||||
|
* - les relations ManyToOne (distributor/broker/tvaMode/paymentType/...) sont
|
||||||
|
* serialisees en OBJETS embarques (avec @id + companyName/code/label), pas en IRI nu ;
|
||||||
|
* - les champs nuls sont OMIS du JSON (skip_null_values) → toujours lire avec `?? null` ;
|
||||||
|
* - les champs comptables et `ribs` sont TOTALEMENT ABSENTS sans permission
|
||||||
|
* accounting.view (gate serveur via ClientReadGroupContextBuilder).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||||
|
import type {
|
||||||
|
AddressFormDraft,
|
||||||
|
ContactFormDraft,
|
||||||
|
RibFormDraft,
|
||||||
|
} from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
|
/** Reference Hydra embarquee minimale (@id toujours present). */
|
||||||
|
export interface HydraRef {
|
||||||
|
'@id': string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
|
||||||
|
export type Relation = HydraRef | string | null | undefined
|
||||||
|
|
||||||
|
/** Site embarque dans une adresse (groupe site:read). */
|
||||||
|
export interface SiteRead extends HydraRef {
|
||||||
|
name?: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Categorie embarquee (groupe category:read). */
|
||||||
|
export interface CategoryRead extends HydraRef {
|
||||||
|
code?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contact embarque (groupe client_contact:read). */
|
||||||
|
export interface ContactRead extends HydraRef {
|
||||||
|
id: number
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
jobTitle?: string | null
|
||||||
|
phonePrimary?: string | null
|
||||||
|
phoneSecondary?: string | null
|
||||||
|
email?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adresse embarquee (groupe client_address:read). */
|
||||||
|
export interface AddressRead extends HydraRef {
|
||||||
|
id: number
|
||||||
|
country?: string | null
|
||||||
|
postalCode?: string | null
|
||||||
|
city?: string | null
|
||||||
|
street?: string | null
|
||||||
|
streetComplement?: string | null
|
||||||
|
billingEmail?: string | null
|
||||||
|
isProspect?: boolean
|
||||||
|
isDelivery?: boolean
|
||||||
|
isBilling?: boolean
|
||||||
|
sites?: SiteRead[]
|
||||||
|
categories?: CategoryRead[]
|
||||||
|
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||||
|
contacts?: Array<HydraRef | string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RIB embarque (groupe client:read:accounting, present ssi accounting.view). */
|
||||||
|
export interface RibRead extends HydraRef {
|
||||||
|
id: number
|
||||||
|
label?: string | null
|
||||||
|
bic?: string | null
|
||||||
|
iban?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client relie (distributeur / courtier) embarque (groupe client:read). */
|
||||||
|
export interface RelatedClientRead extends HydraRef {
|
||||||
|
companyName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail d'un client tel que renvoye par `GET /api/clients/{id}`. Tous les
|
||||||
|
* champs sont optionnels : skip_null_values cote serveur et gating accounting
|
||||||
|
* peuvent omettre n'importe quelle cle.
|
||||||
|
*/
|
||||||
|
export interface ClientDetail extends HydraRef {
|
||||||
|
id: number
|
||||||
|
companyName?: string | null
|
||||||
|
triageService?: boolean
|
||||||
|
isArchived?: boolean
|
||||||
|
categories?: CategoryRead[]
|
||||||
|
distributor?: RelatedClientRead | string | null
|
||||||
|
broker?: RelatedClientRead | string | null
|
||||||
|
contacts?: ContactRead[]
|
||||||
|
addresses?: AddressRead[]
|
||||||
|
ribs?: RibRead[]
|
||||||
|
// Onglet Information
|
||||||
|
description?: string | null
|
||||||
|
competitors?: string | null
|
||||||
|
foundedAt?: string | null
|
||||||
|
employeesCount?: number | null
|
||||||
|
revenueAmount?: string | null
|
||||||
|
profitAmount?: string | null
|
||||||
|
directorName?: string | null
|
||||||
|
// Onglet Comptabilite (present ssi accounting.view)
|
||||||
|
siren?: string | null
|
||||||
|
accountNumber?: string | null
|
||||||
|
nTva?: string | null
|
||||||
|
tvaMode?: Relation
|
||||||
|
paymentDelay?: Relation
|
||||||
|
paymentType?: Relation
|
||||||
|
bank?: Relation
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire 1.10). */
|
||||||
|
export interface AccountingDraft {
|
||||||
|
siren: string | null
|
||||||
|
accountNumber: string | null
|
||||||
|
nTva: string | null
|
||||||
|
tvaModeIri: string | null
|
||||||
|
paymentDelayIri: string | null
|
||||||
|
paymentTypeIri: string | null
|
||||||
|
bankIri: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relation Distributeur/Courtier resolue pour l'affichage en lecture seule. */
|
||||||
|
export interface ClientRelation {
|
||||||
|
type: 'distributeur' | 'courtier' | null
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option de select ({ value, label }) construite a partir de l'embed. */
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
|
||||||
|
export interface CategorySelectOption extends SelectOption {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
|
||||||
|
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
|
||||||
|
*/
|
||||||
|
export interface AddressView {
|
||||||
|
draft: AddressFormDraft
|
||||||
|
siteOptions: SelectOption[]
|
||||||
|
categoryOptions: CategorySelectOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
|
||||||
|
export function iriOf(relation: Relation): string | null {
|
||||||
|
if (relation === null || relation === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof relation === 'string') {
|
||||||
|
return relation
|
||||||
|
}
|
||||||
|
return relation['@id'] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resout la relation Distributeur/Courtier (RG-1.03 : mutuellement exclusives).
|
||||||
|
* Le nom est lu sur l'objet embarque (`companyName`) ; null si la relation est
|
||||||
|
* un IRI nu ou absente.
|
||||||
|
*/
|
||||||
|
export function relationOf(client: ClientDetail): ClientRelation {
|
||||||
|
const nameOf = (rel: RelatedClientRead | string | null | undefined): string | null =>
|
||||||
|
rel && typeof rel === 'object' ? (rel.companyName ?? null) : null
|
||||||
|
|
||||||
|
if (client.distributor) {
|
||||||
|
return { type: 'distributeur', name: nameOf(client.distributor) }
|
||||||
|
}
|
||||||
|
if (client.broker) {
|
||||||
|
return { type: 'courtier', name: nameOf(client.broker) }
|
||||||
|
}
|
||||||
|
return { type: null, name: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
|
||||||
|
export function mapContactToDraft(contact: ContactRead): ContactFormDraft {
|
||||||
|
const phoneSecondary = contact.phoneSecondary ?? null
|
||||||
|
return {
|
||||||
|
id: contact.id,
|
||||||
|
iri: contact['@id'] ?? null,
|
||||||
|
firstName: contact.firstName ?? null,
|
||||||
|
lastName: contact.lastName ?? null,
|
||||||
|
jobTitle: contact.jobTitle ?? null,
|
||||||
|
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
||||||
|
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
|
||||||
|
email: contact.email ?? null,
|
||||||
|
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
|
||||||
|
export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
|
||||||
|
return {
|
||||||
|
id: address.id,
|
||||||
|
isProspect: address.isProspect ?? false,
|
||||||
|
isDelivery: address.isDelivery ?? false,
|
||||||
|
isBilling: address.isBilling ?? false,
|
||||||
|
country: address.country ?? 'France',
|
||||||
|
postalCode: address.postalCode ?? null,
|
||||||
|
city: address.city ?? null,
|
||||||
|
street: address.street ?? null,
|
||||||
|
streetComplement: address.streetComplement ?? null,
|
||||||
|
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
||||||
|
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||||
|
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||||
|
billingEmail: address.billingEmail ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe un RIB embarque vers un brouillon. */
|
||||||
|
export function mapRibToDraft(rib: RibRead): RibFormDraft {
|
||||||
|
return {
|
||||||
|
id: rib.id,
|
||||||
|
label: rib.label ?? null,
|
||||||
|
bic: rib.bic ?? null,
|
||||||
|
iban: rib.iban ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe les champs comptables du client (scalaires + IRI des referentiels). */
|
||||||
|
export function mapAccountingDraft(client: ClientDetail): AccountingDraft {
|
||||||
|
return {
|
||||||
|
siren: client.siren ?? null,
|
||||||
|
accountNumber: client.accountNumber ?? null,
|
||||||
|
nTva: client.nTva ?? null,
|
||||||
|
tvaModeIri: iriOf(client.tvaMode),
|
||||||
|
paymentDelayIri: iriOf(client.paymentDelay),
|
||||||
|
paymentTypeIri: iriOf(client.paymentType),
|
||||||
|
bankIri: iriOf(client.bank),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
|
||||||
|
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
|
||||||
|
* roles metier non-admin), qui laisserait les libelles vides.
|
||||||
|
*/
|
||||||
|
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
|
||||||
|
return (categories ?? []).map(c => ({
|
||||||
|
value: c['@id'],
|
||||||
|
label: c.name ?? c.code ?? c['@id'],
|
||||||
|
code: c.code ?? '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
|
||||||
|
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
|
||||||
|
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed client. */
|
||||||
|
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
|
||||||
|
return (contacts ?? []).map(c => ({
|
||||||
|
value: c['@id'],
|
||||||
|
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
|
||||||
|
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
|
||||||
|
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
|
||||||
|
* `GET` de referentiel — l'affichage reste correct quel que soit le role.
|
||||||
|
*/
|
||||||
|
export function referentialOptionOf(relation: Relation): SelectOption[] {
|
||||||
|
if (!relation || typeof relation === 'string') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const label = (relation.label as string | undefined)
|
||||||
|
?? (relation.name as string | undefined)
|
||||||
|
?? relation['@id']
|
||||||
|
return [{ value: relation['@id'], label }]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
||||||
|
export function mapAddressView(address: AddressRead): AddressView {
|
||||||
|
return {
|
||||||
|
draft: mapAddressToDraft(address),
|
||||||
|
siteOptions: siteOptionsOf(address.sites),
|
||||||
|
categoryOptions: categoryOptionsOf(address.categories),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
||||||
|
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
||||||
|
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
|
||||||
|
* par onglet est gere sur l'ecran d'edition (1.12).
|
||||||
|
*/
|
||||||
|
export function canEditClient(canAny: (codes: string[]) => boolean): boolean {
|
||||||
|
return canAny(['commercial.clients.manage', 'commercial.clients.accounting.manage'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bouton « Archiver » : permission archive ET client encore actif. */
|
||||||
|
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||||
|
return can('commercial.clients.archive') && !isArchived
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bouton « Restaurer » : permission archive ET client deja archive. */
|
||||||
|
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||||
|
return can('commercial.clients.archive') && isArchived
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* Helpers purs de l'ecran « Modification client » (M1 Commercial, 1.12).
|
||||||
|
*
|
||||||
|
* Deux responsabilites, toutes deux testables unitairement (cf. clientEdit.spec.ts) :
|
||||||
|
* 1. Pre-remplissage : mapper le payload `GET /api/clients/{id}` (embed
|
||||||
|
* contacts/adresses/ribs + scalaires) vers les brouillons « plats » edites
|
||||||
|
* par la page et les blocs reutilisables (mappers contacts/adresses/ribs/
|
||||||
|
* comptabilite reutilises depuis clientConsultation).
|
||||||
|
* 2. Scoping STRICT des payloads PATCH (mode strict RG-1.28 / ERP-74) : chaque
|
||||||
|
* onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un
|
||||||
|
* payload mixte — un champ hors-permission = 403 sur l'integralite cote back.
|
||||||
|
*
|
||||||
|
* Ces helpers ne touchent ni a l'API ni a l'etat reactif.
|
||||||
|
*
|
||||||
|
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON
|
||||||
|
* miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de
|
||||||
|
* role et Bureau partage les permissions de Commerciale. Le back l'applique de
|
||||||
|
* maniere fiable (422) ; on laisse remonter ce 422 en toast.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
iriOf,
|
||||||
|
relationOf,
|
||||||
|
type ClientDetail,
|
||||||
|
} from '~/modules/commercial/utils/clientConsultation'
|
||||||
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export interface MainFormDraft {
|
||||||
|
companyName: string | null
|
||||||
|
/** IRI des categories rattachees (M2M). */
|
||||||
|
categoryIris: string[]
|
||||||
|
relationType: 'distributeur' | 'courtier' | null
|
||||||
|
distributorIri: string | null
|
||||||
|
brokerIri: string | null
|
||||||
|
triageService: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Etat « plat » de l'onglet Information (groupe client:write:information). */
|
||||||
|
export interface InformationFormDraft {
|
||||||
|
description: string | null
|
||||||
|
competitors: string | null
|
||||||
|
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||||
|
foundedAt: string | null
|
||||||
|
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||||
|
employeesCount: string | null
|
||||||
|
revenueAmount: string | null
|
||||||
|
profitAmount: string | null
|
||||||
|
directorName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Etat « plat » de l'onglet Comptabilite (groupe client:write:accounting). */
|
||||||
|
export interface AccountingFormDraft {
|
||||||
|
siren: string | null
|
||||||
|
accountNumber: string | null
|
||||||
|
nTva: string | null
|
||||||
|
tvaModeIri: string | null
|
||||||
|
paymentDelayIri: string | null
|
||||||
|
paymentTypeIri: string | null
|
||||||
|
bankIri: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un client. */
|
||||||
|
export interface ClientEditAbilities {
|
||||||
|
/** `commercial.clients.manage` : bloc principal + onglets metier. */
|
||||||
|
canManage: boolean
|
||||||
|
/** `commercial.clients.accounting.view` : visibilite de l'onglet Comptabilite. */
|
||||||
|
canAccountingView: boolean
|
||||||
|
/** `commercial.clients.accounting.manage` : edition de l'onglet Comptabilite. */
|
||||||
|
canAccountingManage: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editabilite resolue par zone d'onglet (deduite des permissions). */
|
||||||
|
export interface TabEditability {
|
||||||
|
/** Bloc principal + onglets Information / Contact / Adresse editables. */
|
||||||
|
businessEditable: boolean
|
||||||
|
/** Onglet Comptabilite present (affiche). */
|
||||||
|
accountingVisible: boolean
|
||||||
|
/** Onglet Comptabilite editable. */
|
||||||
|
accountingEditable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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.
|
||||||
|
*/
|
||||||
|
export function mapMainDraft(client: ClientDetail): MainFormDraft {
|
||||||
|
const relation = relationOf(client)
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyName: client.companyName ?? null,
|
||||||
|
categoryIris: (client.categories ?? []).map(c => c['@id']),
|
||||||
|
relationType: relation.type,
|
||||||
|
distributorIri: iriOf(client.distributor),
|
||||||
|
brokerIri: iriOf(client.broker),
|
||||||
|
triageService: client.triageService === true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe le detail client vers le brouillon de l'onglet Information. */
|
||||||
|
export function mapInformationDraft(client: ClientDetail): InformationFormDraft {
|
||||||
|
return {
|
||||||
|
description: client.description ?? null,
|
||||||
|
competitors: client.competitors ?? null,
|
||||||
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||||
|
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
||||||
|
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
||||||
|
revenueAmount: client.revenueAmount ?? null,
|
||||||
|
profitAmount: client.profitAmount ?? null,
|
||||||
|
directorName: client.directorName ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
|
||||||
|
export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraft {
|
||||||
|
return {
|
||||||
|
siren: client.siren ?? null,
|
||||||
|
accountNumber: client.accountNumber ?? null,
|
||||||
|
nTva: client.nTva ?? null,
|
||||||
|
tvaModeIri: iriOf(client.tvaMode),
|
||||||
|
paymentDelayIri: iriOf(client.paymentDelay),
|
||||||
|
paymentTypeIri: iriOf(client.paymentType),
|
||||||
|
bankIri: iriOf(client.bank),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scoping strict des payloads PATCH ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation
|
||||||
|
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
|
||||||
|
* que la FK correspondant au type choisi, l'autre est forcee a null.
|
||||||
|
*/
|
||||||
|
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
companyName: main.companyName,
|
||||||
|
categories: main.categoryIris,
|
||||||
|
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
||||||
|
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
||||||
|
triageService: main.triageService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
|
||||||
|
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
description: information.description || null,
|
||||||
|
competitors: information.competitors || null,
|
||||||
|
foundedAt: information.foundedAt || null,
|
||||||
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
|
revenueAmount: information.revenueAmount || null,
|
||||||
|
profitAmount: information.profitAmount || null,
|
||||||
|
directorName: information.directorName || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload des scalaires de l'onglet Comptabilite — groupe client:write:accounting
|
||||||
|
* UNIQUEMENT (les RIB passent par la sous-ressource /clients/{id}/ribs). La banque
|
||||||
|
* n'a de sens que pour un Virement (RG-1.12) : forcee a null sinon.
|
||||||
|
*/
|
||||||
|
export function buildAccountingPayload(
|
||||||
|
accounting: AccountingFormDraft,
|
||||||
|
isBankRequired: boolean,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
siren: accounting.siren || null,
|
||||||
|
accountNumber: accounting.accountNumber || null,
|
||||||
|
tvaMode: accounting.tvaModeIri,
|
||||||
|
nTva: accounting.nTva || null,
|
||||||
|
paymentDelay: accounting.paymentDelayIri,
|
||||||
|
paymentType: accounting.paymentTypeIri,
|
||||||
|
bank: isBankRequired ? accounting.bankIri : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload d'un contact (sous-ressource client_contact). */
|
||||||
|
export function buildContactPayload(contact: ContactFormDraft): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
firstName: contact.firstName || null,
|
||||||
|
lastName: contact.lastName || null,
|
||||||
|
jobTitle: contact.jobTitle || null,
|
||||||
|
phonePrimary: contact.phonePrimary || null,
|
||||||
|
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||||
|
email: contact.email || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload d'une adresse (sous-ressource client_address). */
|
||||||
|
export function buildAddressPayload(
|
||||||
|
address: AddressFormDraft,
|
||||||
|
isBillingEmailRequired: boolean,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
isProspect: address.isProspect,
|
||||||
|
isDelivery: address.isDelivery,
|
||||||
|
isBilling: address.isBilling,
|
||||||
|
country: address.country,
|
||||||
|
postalCode: address.postalCode || null,
|
||||||
|
city: address.city || null,
|
||||||
|
street: address.street || null,
|
||||||
|
streetComplement: address.streetComplement || null,
|
||||||
|
categories: address.categoryIris,
|
||||||
|
sites: address.siteIris,
|
||||||
|
contacts: address.contactIris,
|
||||||
|
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload d'un RIB (sous-ressource client_rib). */
|
||||||
|
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
label: rib.label,
|
||||||
|
bic: rib.bic,
|
||||||
|
iban: rib.iban,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gating par permission ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
|
||||||
|
* miroir UI du re-gating champ-par-champ du ClientProcessor) :
|
||||||
|
* - bloc principal + Information/Contact/Adresse : editables ssi `manage` ;
|
||||||
|
* - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`.
|
||||||
|
*
|
||||||
|
* Produit le comportement attendu :
|
||||||
|
* - Admin : tout editable.
|
||||||
|
* - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee.
|
||||||
|
* - Compta (accounting seul, sans manage) : metier readonly, Compta editable.
|
||||||
|
*/
|
||||||
|
export function resolveTabEditability(abilities: ClientEditAbilities): TabEditability {
|
||||||
|
return {
|
||||||
|
businessEditable: abilities.canManage,
|
||||||
|
accountingVisible: abilities.canAccountingView,
|
||||||
|
accountingEditable: abilities.canAccountingManage,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Regles metier pures de l'ecran « Ajouter un client » (M1 Commercial).
|
||||||
|
*
|
||||||
|
* Centralisees ici (hors composant) pour rester testables unitairement et
|
||||||
|
* partagees entre la page de creation et les futurs ecrans d'edition (1.11/1.12).
|
||||||
|
* Ces helpers ne touchent ni a l'API ni a l'etat reactif : ils prennent des
|
||||||
|
* brouillons « plats » et retournent des booleens / nouveaux objets.
|
||||||
|
*
|
||||||
|
* Le back reste la source de verite (les RG sont re-validees serveur) ; ces
|
||||||
|
* regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite).
|
||||||
|
*
|
||||||
|
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement
|
||||||
|
* NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code
|
||||||
|
* de role (roles = IRIs opaques) et Bureau partage les memes permissions que
|
||||||
|
* Commerciale : aucun signal fiable pour distinguer le role cote front. Le back
|
||||||
|
* (ClientProcessor, via BusinessRoleAware) applique la regle de maniere fiable ;
|
||||||
|
* a rebrancher ici des qu'un code de role sera expose dans /api/me.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onglets « coquille » (non encore implementes) : frame vide, passage
|
||||||
|
* automatique a l'onglet suivant (decision Tristan 28/05).
|
||||||
|
*/
|
||||||
|
export const CLIENT_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onglets affiches uniquement en MODIFICATION (selon le role), jamais a la
|
||||||
|
* creation : Statistiques / Rapports / Echanges. A rebrancher dans les ecrans
|
||||||
|
* d'edition (1.11/1.12) via l'option `includeEditOnlyTabs`.
|
||||||
|
*/
|
||||||
|
export const CLIENT_FORM_EDIT_ONLY_TABS = ['statistics', 'reports', 'exchanges'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'ordre des onglets du formulaire client.
|
||||||
|
* - L'onglet Comptabilite n'est present que si l'utilisateur a `accounting.view`
|
||||||
|
* (Bureau / Commerciale ne le voient pas).
|
||||||
|
* - Les onglets edit-only (Statistiques / Rapports / Echanges) sont exclus par
|
||||||
|
* defaut (creation) ; passer `includeEditOnlyTabs: true` pour les afficher en
|
||||||
|
* modification.
|
||||||
|
* Ordre aligne sur la spec M1 § Ecran « Ajouter un client ».
|
||||||
|
*/
|
||||||
|
export function buildClientFormTabKeys(
|
||||||
|
canAccountingView: boolean,
|
||||||
|
options: { includeEditOnlyTabs?: boolean } = {},
|
||||||
|
): string[] {
|
||||||
|
const keys = ['information', 'contact', 'address', 'transport']
|
||||||
|
if (canAccountingView) {
|
||||||
|
keys.push('accounting')
|
||||||
|
}
|
||||||
|
if (options.includeEditOnlyTabs) {
|
||||||
|
keys.push(...CLIENT_FORM_EDIT_ONLY_TABS)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */
|
||||||
|
export interface ContactDraft {
|
||||||
|
firstName: string | null
|
||||||
|
lastName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drapeaux d'usage d'une adresse (RG-1.06/07/08/11). */
|
||||||
|
export interface AddressFlagsDraft {
|
||||||
|
isProspect: boolean
|
||||||
|
isDelivery: boolean
|
||||||
|
isBilling: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||||
|
function isFilled(value: string | null | undefined): boolean {
|
||||||
|
return value !== null && value !== undefined && value.trim() !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.05 : un contact est valide des qu'il porte un nom OU un prenom.
|
||||||
|
*/
|
||||||
|
export function isContactNamed(contact: ContactDraft): boolean {
|
||||||
|
return isFilled(contact.firstName) || isFilled(contact.lastName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.14 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
|
||||||
|
* contact nomme (nom ou prenom).
|
||||||
|
*/
|
||||||
|
export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean {
|
||||||
|
return contacts.some(isContactNamed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
|
||||||
|
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
|
||||||
|
* Facturation ne sont coches.
|
||||||
|
*/
|
||||||
|
export function canSelectProspect(flags: AddressFlagsDraft): boolean {
|
||||||
|
return !flags.isDelivery && !flags.isBilling
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.06/07/08 : Livraison et Facturation ne sont selectionnables que si
|
||||||
|
* Prospect n'est pas coche.
|
||||||
|
*/
|
||||||
|
export function canSelectDeliveryOrBilling(flags: AddressFlagsDraft): boolean {
|
||||||
|
return !flags.isProspect
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique l'exclusivite Prospect / (Livraison|Facturation) au changement d'un
|
||||||
|
* drapeau. Cocher Prospect efface Livraison + Facturation ; cocher Livraison ou
|
||||||
|
* Facturation efface Prospect. Decocher n'a aucun effet de bord. Retourne un
|
||||||
|
* nouvel objet (pas de mutation de l'entree).
|
||||||
|
*/
|
||||||
|
export function applyProspectExclusivity(
|
||||||
|
flags: AddressFlagsDraft,
|
||||||
|
field: keyof AddressFlagsDraft,
|
||||||
|
value: boolean,
|
||||||
|
): AddressFlagsDraft {
|
||||||
|
const next: AddressFlagsDraft = { ...flags, [field]: value }
|
||||||
|
|
||||||
|
if (value && field === 'isProspect') {
|
||||||
|
next.isDelivery = false
|
||||||
|
next.isBilling = false
|
||||||
|
}
|
||||||
|
else if (value && (field === 'isDelivery' || field === 'isBilling')) {
|
||||||
|
next.isProspect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.11 : l'email de facturation n'est visible/obligatoire que si l'adresse
|
||||||
|
* est une adresse de facturation.
|
||||||
|
*/
|
||||||
|
export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
|
||||||
|
return flags.isBilling
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
|
||||||
|
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
|
||||||
|
|
||||||
|
/** Code stable du type de reglement « lettre de change » (RG-1.13). */
|
||||||
|
const PAYMENT_TYPE_LCR = 'LCR'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.12 : la banque est obligatoire lorsque le type de reglement est un
|
||||||
|
* virement.
|
||||||
|
*/
|
||||||
|
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||||
|
return code === PAYMENT_TYPE_TRANSFER
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.13 : au moins un RIB complet est obligatoire lorsque le type de reglement
|
||||||
|
* est une LCR.
|
||||||
|
*/
|
||||||
|
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||||
|
return code === PAYMENT_TYPE_LCR
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Accordeon de permissions groupees par module : un panneau par module,
|
||||||
|
avec compteur (selectionnees/total) dans le titre, case "Tout selectionner"
|
||||||
|
et liste des permissions individuelles. Source unique de cette UX, utilisee
|
||||||
|
par RoleDrawer (permissions du role) et UserRbacDrawer (permissions directes). -->
|
||||||
|
<MalioAccordion v-model="openModules">
|
||||||
|
<MalioAccordionItem
|
||||||
|
v-for="group in groupsByModule"
|
||||||
|
:key="group.module"
|
||||||
|
:value="group.module"
|
||||||
|
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
|
||||||
|
header-class="capitalize"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<!-- Tout selectionner pour ce module -->
|
||||||
|
<MalioCheckbox
|
||||||
|
:id="`${idPrefix}-group-${group.module}`"
|
||||||
|
:label="t('admin.roles.permissions.selectAll')"
|
||||||
|
:model-value="allSelectedFor(group)"
|
||||||
|
label-class="font-semibold text-sm text-neutral-700"
|
||||||
|
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="perm in group.permissions"
|
||||||
|
:id="`${idPrefix}-perm-${perm.id}`"
|
||||||
|
:key="perm.id"
|
||||||
|
:label="perm.label"
|
||||||
|
:model-value="selectedIds.has(perm.id)"
|
||||||
|
label-class="text-sm text-neutral-600"
|
||||||
|
@update:model-value="(val: boolean) => emit('toggle', perm.id, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PermissionModule } from '~/shared/types/rbac'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Groupes de permissions a afficher, un par module. */
|
||||||
|
groupsByModule: PermissionModule[]
|
||||||
|
/** Ids des permissions actuellement selectionnees. */
|
||||||
|
selectedIds: Set<number>
|
||||||
|
/** Prefixe pour les ids HTML : evite les collisions si plusieurs accordeons coexistent (ex: "role" vs "direct"). */
|
||||||
|
idPrefix: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggle: [permissionId: number, selected: boolean]
|
||||||
|
'toggle-all': [module: string, selected: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Modules ouverts dans l'accordeon (mode multiple). Etat local : chaque instance
|
||||||
|
// du composant garde sa propre liste, pas de partage entre drawers.
|
||||||
|
const openModules = ref<string[]>([])
|
||||||
|
|
||||||
|
// Nombre de permissions selectionnees pour un module donne.
|
||||||
|
function selectedCountFor(group: PermissionModule): number {
|
||||||
|
return group.permissions.filter(p => props.selectedIds.has(p.id)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrai si toutes les permissions du module sont selectionnees.
|
||||||
|
function allSelectedFor(group: PermissionModule): boolean {
|
||||||
|
return group.permissions.length > 0 && selectedCountFor(group) === group.permissions.length
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="rounded-lg border border-neutral-200 overflow-hidden">
|
|
||||||
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
|
|
||||||
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
|
|
||||||
<MalioCheckbox
|
|
||||||
:id="`group-${module}`"
|
|
||||||
:label="moduleLabel"
|
|
||||||
:model-value="allSelected"
|
|
||||||
label-class="font-semibold text-sm text-neutral-700 capitalize"
|
|
||||||
@update:model-value="toggleAll"
|
|
||||||
/>
|
|
||||||
<span class="ml-auto text-xs text-neutral-400">
|
|
||||||
{{ selectedCount }}/{{ permissions.length }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Liste des permissions individuelles -->
|
|
||||||
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
|
|
||||||
<MalioCheckbox
|
|
||||||
v-for="perm in permissions"
|
|
||||||
:key="perm.id"
|
|
||||||
:id="`perm-${perm.id}`"
|
|
||||||
:label="perm.label"
|
|
||||||
:model-value="selectedIds.has(perm.id)"
|
|
||||||
label-class="text-sm text-neutral-600"
|
|
||||||
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Permission } from '~/shared/types/rbac'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
module: string
|
|
||||||
moduleLabel: string
|
|
||||||
permissions: Permission[]
|
|
||||||
selectedIds: Set<number>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
toggle: [permissionId: number, selected: boolean]
|
|
||||||
toggleAll: [module: string, selected: boolean]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Nombre de permissions selectionnees dans ce groupe
|
|
||||||
const selectedCount = computed(() =>
|
|
||||||
props.permissions.filter(p => props.selectedIds.has(p.id)).length
|
|
||||||
)
|
|
||||||
|
|
||||||
// Vrai si toutes les permissions du groupe sont selectionnees
|
|
||||||
const allSelected = computed(() =>
|
|
||||||
props.permissions.length > 0 && selectedCount.value === props.permissions.length
|
|
||||||
)
|
|
||||||
|
|
||||||
// Emet l'evenement de bascule pour une permission individuelle
|
|
||||||
function togglePermission(id: number, selected: boolean) {
|
|
||||||
emit('toggle', id, selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emet l'evenement de bascule pour toutes les permissions du groupe
|
|
||||||
function toggleAll(selected: boolean) {
|
|
||||||
emit('toggleAll', props.module, selected)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
|
|
||||||
drawer-class="w-full max-w-lg"
|
drawer-class="w-full max-w-lg"
|
||||||
|
header-class="border-b border-black"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">
|
||||||
|
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
||||||
<!-- Champs du role -->
|
<!-- Champs du role -->
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
@@ -44,28 +50,28 @@
|
|||||||
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||||
{{ t('admin.roles.permissions.noPermissions') }}
|
{{ t('admin.roles.permissions.noPermissions') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4">
|
<PermissionAccordion
|
||||||
<PermissionGroup
|
v-else
|
||||||
v-for="group in permissionsByModule"
|
:groups-by-module="permissionsByModule"
|
||||||
:key="group.module"
|
|
||||||
:module="group.module"
|
|
||||||
:module-label="group.module"
|
|
||||||
:permissions="group.permissions"
|
|
||||||
:selected-ids="selectedPermissionIds"
|
:selected-ids="selectedPermissionIds"
|
||||||
|
id-prefix="role"
|
||||||
@toggle="handleTogglePermission"
|
@toggle="handleTogglePermission"
|
||||||
@toggle-all="handleToggleAll"
|
@toggle-all="handleToggleAll"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Boutons -->
|
</form>
|
||||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
|
||||||
|
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||||
|
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||||
|
<template #footer>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="isEditMode"
|
v-if="isEditMode"
|
||||||
:label="t('common.delete')"
|
:label="t('common.delete')"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
icon-name="mdi:delete-outline"
|
icon-name="mdi:delete-outline"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
:disabled="role?.isSystem"
|
:disabled="role?.isSystem"
|
||||||
@click="emit('delete')"
|
@click="emit('delete')"
|
||||||
/>
|
/>
|
||||||
@@ -73,26 +79,22 @@
|
|||||||
v-else
|
v-else
|
||||||
:label="t('common.cancel')"
|
:label="t('common.cancel')"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
@click="emit('update:modelValue', false)"
|
@click="emit('update:modelValue', false)"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.save')"
|
:label="t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
:disabled="saving || permissionsLoadFailed"
|
:disabled="saving || permissionsLoadFailed"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</form>
|
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Permission, Role } from '~/shared/types/rbac'
|
import type { Permission, PermissionModule, Role } from '~/shared/types/rbac'
|
||||||
|
|
||||||
interface PermissionModule {
|
|
||||||
module: string
|
|
||||||
permissions: Permission[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })"
|
drawer-class="w-full max-w-[450px]"
|
||||||
drawer-class="w-full max-w-lg"
|
header-class="border-b border-black"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-6 p-4">
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">
|
||||||
|
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col space-y-4 py-4">
|
||||||
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
||||||
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
||||||
<div
|
<div
|
||||||
@@ -35,11 +41,13 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Section Roles -->
|
<!-- Section Roles -->
|
||||||
<div>
|
<!-- !mt-0 : la MalioCheckbox au-dessus expose son slot message (16px),
|
||||||
|
qui couvre deja l'ecart attendu — pas besoin du space-y-4 ici. -->
|
||||||
|
<div class="!mt-0">
|
||||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||||
{{ t('admin.users.drawer.rolesSection') }}
|
{{ t('admin.users.drawer.rolesSection') }}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col">
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
v-for="role in allRoles"
|
v-for="role in allRoles"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
@@ -60,19 +68,15 @@
|
|||||||
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||||
{{ t('admin.roles.permissions.noPermissions') }}
|
{{ t('admin.roles.permissions.noPermissions') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4">
|
<PermissionAccordion
|
||||||
<PermissionGroup
|
v-else
|
||||||
v-for="group in permissionsByModule"
|
:groups-by-module="permissionsByModule"
|
||||||
:key="group.module"
|
|
||||||
:module="group.module"
|
|
||||||
:module-label="group.module"
|
|
||||||
:permissions="group.permissions"
|
|
||||||
:selected-ids="selectedDirectPermissionIds"
|
:selected-ids="selectedDirectPermissionIds"
|
||||||
|
id-prefix="direct"
|
||||||
@toggle="handleTogglePermission"
|
@toggle="handleTogglePermission"
|
||||||
@toggle-all="handleToggleAll"
|
@toggle-all="handleToggleAll"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
||||||
<div>
|
<div>
|
||||||
@@ -82,7 +86,7 @@
|
|||||||
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
|
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
|
||||||
{{ t('admin.sites.noSites') }}
|
{{ t('admin.sites.noSites') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col">
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
v-for="site in allSites"
|
v-for="site in allSites"
|
||||||
:id="`site-${site.id}`"
|
:id="`site-${site.id}`"
|
||||||
@@ -103,33 +107,32 @@
|
|||||||
<EffectivePermissions :permissions="effectivePermissions" />
|
<EffectivePermissions :permissions="effectivePermissions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Boutons -->
|
</div>
|
||||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
|
||||||
|
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||||
|
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||||
|
<template #footer>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.cancel')"
|
:label="t('common.cancel')"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
@click="emit('update:modelValue', false)"
|
@click="emit('update:modelValue', false)"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.save')"
|
:label="t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
:disabled="saving || loadFailed"
|
:disabled="saving || loadFailed"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
import type { Permission, PermissionModule, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
||||||
import type { Site } from '~/shared/types/sites'
|
import type { Site } from '~/shared/types/sites'
|
||||||
|
|
||||||
interface PermissionModule {
|
|
||||||
module: string
|
|
||||||
permissions: Permission[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|||||||
@@ -1,95 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
|
||||||
{{ t('admin.auditLog.title') }}
|
{{ t('admin.auditLog.title') }}
|
||||||
</h1>
|
<template #actions>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filtres -->
|
|
||||||
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
|
|
||||||
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
|
|
||||||
leur `label` flottant interne pour ne pas mixer deux patterns de label.
|
|
||||||
A revoir une fois le composant calendar Malio développé -->
|
|
||||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
|
|
||||||
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
|
|
||||||
exposera un datetime picker. Cf. exception documentee dans
|
|
||||||
CLAUDE.md (section "Composants formulaires"). -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.date_from') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="filters.performedAtAfter"
|
|
||||||
type="datetime-local"
|
|
||||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<!-- TODO(malio-ui): idem ci-dessus. -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.date_to') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="filters.performedAtBefore"
|
|
||||||
type="datetime-local"
|
|
||||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.entity_type') }}
|
|
||||||
</label>
|
|
||||||
<div class="[&>div>div]:!mt-0">
|
|
||||||
<MalioSelectCheckbox
|
|
||||||
v-model="selectedEntityTypes"
|
|
||||||
:options="entityTypeOptions"
|
|
||||||
:display-select-all="true"
|
|
||||||
:display-tag="true"
|
|
||||||
min-width="w-full"
|
|
||||||
text-field="text-sm"
|
|
||||||
text-value="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.user') }}
|
|
||||||
</label>
|
|
||||||
<MalioInputText
|
|
||||||
v-model="performedByInput"
|
|
||||||
icon-name="mdi:account-search"
|
|
||||||
input-class="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.action') }}
|
|
||||||
</label>
|
|
||||||
<div class="[&>div>div]:!mt-0">
|
|
||||||
<MalioSelect
|
|
||||||
v-model="actionValue"
|
|
||||||
:options="actionOptions"
|
|
||||||
text-field="text-sm"
|
|
||||||
text-value="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
:label="t('audit.filters.reset')"
|
:label="t('audit.filters.title')"
|
||||||
button-class="text-xs"
|
icon-name="mdi:tune"
|
||||||
@click="resetFilters"
|
icon-position="left"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="w-[184px] justify-start gap-4 text-black"
|
||||||
|
@click="openFilters"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</section>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Tableau -->
|
<!-- Tableau -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-4"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="rows"
|
:items="rows"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
@@ -123,12 +50,103 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioDataTable>
|
</MalioDataTable>
|
||||||
|
|
||||||
|
<!-- Drawer de filtres : etat brouillon, applique uniquement au clic sur
|
||||||
|
"Voir les resultats". `body-class="p-0"` pour que l'accordeon aille
|
||||||
|
bord a bord (les items portent leur propre px-7). -->
|
||||||
|
<MalioDrawer
|
||||||
|
v-model="filterDrawerOpen"
|
||||||
|
drawer-class="max-w-[450px]"
|
||||||
|
body-class="p-0"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">{{ t('audit.filters.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioAccordion>
|
||||||
|
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
|
||||||
|
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
|
||||||
|
<!-- pb-4 sur les labels Du/Au : simule le slot message
|
||||||
|
du MalioDateTime voisin pour qu'items-center recentre
|
||||||
|
le label sur le centre visible du champ. -->
|
||||||
|
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3">
|
||||||
|
<span class="pb-4">{{ t('audit.filters.date_from') }}</span>
|
||||||
|
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
|
||||||
|
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="draftDateFrom"
|
||||||
|
:max="draftDateTo ?? undefined"
|
||||||
|
/>
|
||||||
|
<span class="pb-4">{{ t('audit.filters.date_to') }}</span>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="draftDateTo"
|
||||||
|
:min="draftDateFrom ?? undefined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Type d'entite : cases a cocher (multi-selection) -->
|
||||||
|
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in entityTypeOptions"
|
||||||
|
:id="`filter-entity-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="draftEntityTypes.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => toggleEntity(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Action : boutons radio (selection unique, '' = toutes) -->
|
||||||
|
<MalioAccordionItem :title="t('audit.filters.action')" value="action">
|
||||||
|
<MalioRadioButton
|
||||||
|
v-for="opt in actionOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
v-model="draftAction"
|
||||||
|
name="audit-action"
|
||||||
|
:value="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
group-class="mt-0"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Utilisateur : recherche texte (ILIKE partiel cote backend) -->
|
||||||
|
<MalioAccordionItem :title="t('audit.filters.user')" value="user">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="draftPerformedBy"
|
||||||
|
icon-name="mdi:account-search"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
:label="t('audit.filters.reset')"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('audit.filters.apply')"
|
||||||
|
button-class="w-[170px]"
|
||||||
|
@click="applyFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
|
||||||
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
v-model="drawerOpen"
|
v-model="drawerOpen"
|
||||||
:title="drawerTitle"
|
|
||||||
drawer-class="max-w-2xl"
|
drawer-class="max-w-2xl"
|
||||||
>
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">
|
||||||
|
{{ drawerTitle }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
<div v-if="selectedEntry">
|
<div v-if="selectedEntry">
|
||||||
<AuditLogDetail :entry="selectedEntry" />
|
<AuditLogDetail :entry="selectedEntry" />
|
||||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||||
@@ -149,12 +167,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||||
|
|
||||||
const { t, te } = useI18n()
|
const { t, te } = useI18n()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
||||||
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
||||||
@@ -173,8 +192,11 @@ if (!can('core.audit_log.view')) {
|
|||||||
|
|
||||||
useHead({ title: t('admin.auditLog.title') })
|
useHead({ title: t('admin.auditLog.title') })
|
||||||
|
|
||||||
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
// Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
|
||||||
// CLAUDE.md "Tableau : pas de persistance URL").
|
// persiste dans l'URL (cf. regle CLAUDE.md "Tableau : pas de persistance URL").
|
||||||
|
// `performedAtAfter`/`performedAtBefore` stockent une date+heure ISO naive
|
||||||
|
// (`YYYY-MM-DDTHH:MM:00`, fournie par MalioDateTime), convertie en ISO UTC
|
||||||
|
// au moment du fetch.
|
||||||
const filters = reactive<AuditLogFilters>({
|
const filters = reactive<AuditLogFilters>({
|
||||||
performedAtAfter: undefined,
|
performedAtAfter: undefined,
|
||||||
performedAtBefore: undefined,
|
performedAtBefore: undefined,
|
||||||
@@ -185,26 +207,23 @@ const filters = reactive<AuditLogFilters>({
|
|||||||
itemsPerPage: 10,
|
itemsPerPage: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
// Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
|
||||||
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
// uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
|
||||||
const selectedEntityTypes = ref<(string | number)[]>([])
|
// fermant le drawer sans relancer de requete.
|
||||||
|
const filterDrawerOpen = ref(false)
|
||||||
|
const draftDateFrom = ref<string | null>(null)
|
||||||
|
const draftDateTo = ref<string | null>(null)
|
||||||
|
const draftEntityTypes = ref<string[]>([])
|
||||||
|
const draftAction = ref<string>('')
|
||||||
|
const draftPerformedBy = ref<string>('')
|
||||||
|
|
||||||
|
// Liste des entity types (distincts) pour alimenter les cases a cocher.
|
||||||
const entityTypes = ref<string[]>([])
|
const entityTypes = ref<string[]>([])
|
||||||
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
|
|
||||||
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
|
|
||||||
const entityTypeOptions = computed(() =>
|
const entityTypeOptions = computed(() =>
|
||||||
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
// Actions : '' = "toutes". Sert d'options aux boutons radio.
|
||||||
// pas binder directement un `string | undefined` reactive.
|
|
||||||
const performedByInput = ref<string>('')
|
|
||||||
|
|
||||||
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
|
|
||||||
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
|
|
||||||
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
|
|
||||||
// car MalioSelect grise visuellement les options dont la valeur est `null`
|
|
||||||
// (Select.vue:137) — on utilise donc une chaine vide comme sentinelle.
|
|
||||||
const actionValue = ref<string>('')
|
|
||||||
const actionOptions = [
|
const actionOptions = [
|
||||||
{ value: '', label: t('audit.filters.all_actions') },
|
{ value: '', label: t('audit.filters.all_actions') },
|
||||||
{ value: 'create', label: t('audit.action.create') },
|
{ value: 'create', label: t('audit.action.create') },
|
||||||
@@ -259,29 +278,55 @@ const isFiltered = computed(() =>
|
|||||||
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
||||||
let requestToken = 0
|
let requestToken = 0
|
||||||
|
|
||||||
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
// Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
|
||||||
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
// reouverture reflete les filtres actifs.
|
||||||
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
function openFilters(): void {
|
||||||
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
draftDateFrom.value = filters.performedAtAfter ?? null
|
||||||
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
draftDateTo.value = filters.performedAtBefore ?? null
|
||||||
// explicitement apres la liberation.
|
draftEntityTypes.value = Array.isArray(filters.entityType)
|
||||||
let watchersSuspended = false
|
? [...filters.entityType]
|
||||||
|
: (filters.entityType ? [filters.entityType] : [])
|
||||||
|
draftAction.value = filters.action ?? ''
|
||||||
|
draftPerformedBy.value = filters.performedBy ?? ''
|
||||||
|
filterDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bascule un type d'entite dans le brouillon (multi-selection). Les valeurs
|
||||||
|
// sont uniques par construction (v-for sur entityTypeOptions), pas besoin de Set.
|
||||||
|
function toggleEntity(value: string, selected: boolean): void {
|
||||||
|
draftEntityTypes.value = selected
|
||||||
|
? [...draftEntityTypes.value, value]
|
||||||
|
: draftEntityTypes.value.filter(v => v !== value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Reinitialiser" : vide le brouillon ET les filtres actifs, puis recharge.
|
||||||
|
// La remise a zero s'applique immediatement (la table revient a la liste
|
||||||
|
// complete) ; le drawer reste ouvert pour montrer le formulaire vide.
|
||||||
|
function resetFilters(): void {
|
||||||
|
draftDateFrom.value = null
|
||||||
|
draftDateTo.value = null
|
||||||
|
draftEntityTypes.value = []
|
||||||
|
draftAction.value = ''
|
||||||
|
draftPerformedBy.value = ''
|
||||||
|
|
||||||
async function resetFilters(): Promise<void> {
|
|
||||||
watchersSuspended = true
|
|
||||||
filters.performedAtAfter = undefined
|
filters.performedAtAfter = undefined
|
||||||
filters.performedAtBefore = undefined
|
filters.performedAtBefore = undefined
|
||||||
filters.entityType = undefined
|
filters.entityType = undefined
|
||||||
filters.performedBy = undefined
|
|
||||||
filters.action = undefined
|
filters.action = undefined
|
||||||
|
filters.performedBy = undefined
|
||||||
filters.page = 1
|
filters.page = 1
|
||||||
selectedEntityTypes.value = []
|
loadEntries()
|
||||||
performedByInput.value = ''
|
}
|
||||||
actionValue.value = ''
|
|
||||||
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
// "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
|
||||||
// leur execution avec le flag `true`, puis on libere.
|
function applyFilters(): void {
|
||||||
await nextTick()
|
filters.performedAtAfter = draftDateFrom.value ?? undefined
|
||||||
watchersSuspended = false
|
filters.performedAtBefore = draftDateTo.value ?? undefined
|
||||||
|
filters.entityType = draftEntityTypes.value.length > 0 ? [...draftEntityTypes.value] : undefined
|
||||||
|
filters.action = draftAction.value === '' ? undefined : draftAction.value
|
||||||
|
filters.performedBy = draftPerformedBy.value.trim() === '' ? undefined : draftPerformedBy.value.trim()
|
||||||
|
filters.page = 1
|
||||||
|
filterDrawerOpen.value = false
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +336,8 @@ async function loadEntries(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchLogsCached({
|
const data = await fetchLogsCached({
|
||||||
...filters,
|
...filters,
|
||||||
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
|
// MalioDateTime fournit une date+heure sans fuseau (heure locale) ;
|
||||||
|
// on la convertit en ISO UTC pour l'API (bornes exactes, intervalle inclusif).
|
||||||
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
||||||
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
||||||
})
|
})
|
||||||
@@ -300,13 +346,19 @@ async function loadEntries(): Promise<void> {
|
|||||||
if (token !== requestToken) return
|
if (token !== requestToken) return
|
||||||
entries.value = data.member ?? []
|
entries.value = data.member ?? []
|
||||||
totalItems.value = data.totalItems ?? 0
|
totalItems.value = data.totalItems ?? 0
|
||||||
} catch {
|
} catch (err) {
|
||||||
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas
|
// useAuditLog appelle useApi avec { toast: false } pour ne pas multiplier
|
||||||
// laisser l'utilisateur croire que les donnees affichees sont a jour.
|
// les toasts, donc c'est ici qu'on fait remonter l'erreur. Sans ce log+toast,
|
||||||
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog.
|
// une RangeError de `toIso` (date invalide) ou une 500 API laissait l'utilisateur
|
||||||
|
// devant une table vide indistinguable d'un filtre a zero resultat.
|
||||||
if (token === requestToken) {
|
if (token === requestToken) {
|
||||||
entries.value = []
|
entries.value = []
|
||||||
totalItems.value = 0
|
totalItems.value = 0
|
||||||
|
console.error('[audit-log] loadEntries failed', err)
|
||||||
|
toast.error({
|
||||||
|
title: t('audit.error.title'),
|
||||||
|
message: t('audit.error.message'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (token === requestToken) {
|
if (token === requestToken) {
|
||||||
@@ -315,14 +367,9 @@ async function loadEntries(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
|
|
||||||
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
|
|
||||||
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
|
|
||||||
const debouncedReload = debounce(() => loadEntries(), 300)
|
|
||||||
|
|
||||||
function toIso(localDateTime: string): string {
|
function toIso(localDateTime: string): string {
|
||||||
// datetime-local n'a pas de timezone : on assume heure locale et on
|
// MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
|
||||||
// laisse le navigateur generer l'ISO via Date().
|
// on laisse Date() generer l'ISO UTC correspondant pour l'API.
|
||||||
return new Date(localDateTime).toISOString()
|
return new Date(localDateTime).toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,53 +415,16 @@ function onPerPageChange(value: number): void {
|
|||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
|
|
||||||
watch(selectedEntityTypes, values => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
|
|
||||||
filters.page = 1
|
|
||||||
loadEntries()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync MalioSelect action -> filters.action.
|
|
||||||
watch(actionValue, value => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.action = value === '' ? undefined : value
|
|
||||||
filters.page = 1
|
|
||||||
loadEntries()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
|
|
||||||
// refetch par caractere. Le reset passe par debouncedReload egalement pour
|
|
||||||
// coalescer si plusieurs watchers tirent en meme temps.
|
|
||||||
watch(performedByInput, value => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.performedBy = value === '' ? undefined : value
|
|
||||||
filters.page = 1
|
|
||||||
debouncedReload()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Synchronisation reactive : tout changement de dates declenche un fetch +
|
|
||||||
// reset de la pagination a la page 1.
|
|
||||||
watch(
|
|
||||||
() => [filters.performedAtAfter, filters.performedAtBefore],
|
|
||||||
() => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.page = 1
|
|
||||||
loadEntries()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Charge les entity types en parallele de la liste principale : un
|
// Charge les entity types ET la liste principale en parallele (TTFD divise
|
||||||
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
// par 2 sur un backend lent). Le `.catch` du premier garantit qu'un echec
|
||||||
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
|
// de /audit-log-entity-types ne bloque pas l'affichage du tableau —
|
||||||
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
|
// l'utilisateur perd juste le filtre, pas la page entiere.
|
||||||
try {
|
await Promise.all([
|
||||||
entityTypes.value = await fetchEntityTypes()
|
fetchEntityTypes()
|
||||||
} catch {
|
.then(types => { entityTypes.value = types })
|
||||||
entityTypes.value = []
|
.catch(() => { entityTypes.value = [] }),
|
||||||
}
|
loadEntries(),
|
||||||
await loadEntries()
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- En-tete -->
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
|
||||||
{{ t('admin.roles.title') }}
|
{{ t('admin.roles.title') }}
|
||||||
</h1>
|
<template #actions>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="can('core.roles.manage')"
|
v-if="can('core.roles.manage')"
|
||||||
:label="t('admin.roles.newRole')"
|
:label="t('admin.roles.newRole')"
|
||||||
@@ -12,17 +10,22 @@
|
|||||||
icon-position="left"
|
icon-position="left"
|
||||||
@click="openCreateDrawer"
|
@click="openCreateDrawer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Table des roles -->
|
<!-- Table des roles — pagination serveur via usePaginatedList (#73). -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-6"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="roleItems"
|
:items="roleItems"
|
||||||
:total-items="roles.length"
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.roles.noRoles')"
|
:empty-message="t('admin.roles.noRoles')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
>
|
>
|
||||||
<template #cell-code="{ item }">
|
<template #cell-code="{ item }">
|
||||||
<span class="font-mono text-xs">{{ item.code }}</span>
|
<span class="font-mono text-xs">{{ item.code }}</span>
|
||||||
@@ -68,8 +71,17 @@ const canManage = computed(() => can('core.roles.manage'))
|
|||||||
|
|
||||||
useHead({ title: t('admin.roles.title') })
|
useHead({ title: t('admin.roles.title') })
|
||||||
|
|
||||||
const roles = ref<Role[]>([])
|
// Pagination serveur via le composable partage (#73).
|
||||||
const loading = ref(false)
|
const {
|
||||||
|
items: roles,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadRoles,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = usePaginatedList<Role>({ url: '/roles' })
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'label', label: t('admin.roles.table.label') },
|
{ key: 'label', label: t('admin.roles.table.label') },
|
||||||
@@ -104,25 +116,6 @@ const deleteModalOpen = ref(false)
|
|||||||
const roleToDelete = ref<Role | null>(null)
|
const roleToDelete = ref<Role | null>(null)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
// Charger la liste des roles
|
|
||||||
async function loadRoles() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ member: Role[] }>(
|
|
||||||
'/roles',
|
|
||||||
{},
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
roles.value = data.member
|
|
||||||
} catch {
|
|
||||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
|
||||||
// requete reussie avant une perte reseau ou 403).
|
|
||||||
roles.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
function openCreateDrawer() {
|
||||||
selectedRole.value = null
|
selectedRole.value = null
|
||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- En-tete -->
|
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
|
||||||
{{ t('admin.users.title') }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table des utilisateurs -->
|
<!-- Table des utilisateurs — pagination serveur via usePaginatedList (#73). -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-6"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="userItems"
|
:items="userItems"
|
||||||
:total-items="users.length"
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.users.noUsers')"
|
:empty-message="t('admin.users.noUsers')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
>
|
>
|
||||||
<template #cell-admin="{ item }">
|
<template #cell-admin="{ item }">
|
||||||
<span
|
<span
|
||||||
@@ -40,15 +39,26 @@
|
|||||||
import type { UserListItem } from '~/shared/types/rbac'
|
import type { UserListItem } from '~/shared/types/rbac'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
|
|
||||||
useHead({ title: t('admin.users.title') })
|
useHead({ title: t('admin.users.title') })
|
||||||
|
|
||||||
const canManage = computed(() => can('core.users.manage'))
|
const canManage = computed(() => can('core.users.manage'))
|
||||||
|
|
||||||
const users = ref<UserListItem[]>([])
|
// Pagination serveur via le composable partage (#73). Le payload `users`
|
||||||
const loading = ref(false)
|
// reste leger (pas de detail RBAC dans la liste — cf. commentaire colonne
|
||||||
|
// "Sites" plus bas) ce qui rend la pagination 10/25/50 par page confortable.
|
||||||
|
const {
|
||||||
|
items: users,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadUsers,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = usePaginatedList<UserListItem>({ url: '/users' })
|
||||||
|
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedUser = ref<UserListItem | null>(null)
|
const selectedUser = ref<UserListItem | null>(null)
|
||||||
|
|
||||||
@@ -73,21 +83,6 @@ const userItems = computed(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function loadUsers() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
|
|
||||||
users.value = usersData.member
|
|
||||||
} catch {
|
|
||||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
|
||||||
// requete reussie avant une perte reseau ou 403). Pas de toast par
|
|
||||||
// design ici : on laisse la liste vide parler d'elle-meme.
|
|
||||||
users.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserById(id: number): UserListItem | undefined {
|
function getUserById(id: number): UserListItem | undefined {
|
||||||
return users.value.find(u => u.id === id)
|
return users.value.find(u => u.id === id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
<PageHeader>{{ $t('dashboard.title') }}</PageHeader>
|
||||||
<p class="mt-4 text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
<p class="text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/>
|
<img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/>
|
||||||
</span>
|
</span>
|
||||||
<form
|
<form
|
||||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
class="mt-8 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||||
@submit.prevent="handleSubmit"
|
@submit.prevent="handleSubmit"
|
||||||
>
|
>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
/>
|
/>
|
||||||
<p class="font-bold">v{{ version }}</p>
|
<p class="mt-6 font-bold">v{{ version }}</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const { resetSidebar } = useSidebar()
|
|||||||
const { resetModules } = useModules()
|
const { resetModules } = useModules()
|
||||||
const { resetCurrentSite } = useCurrentSite()
|
const { resetCurrentSite } = useCurrentSite()
|
||||||
const { resetAuditLog } = useAuditLog()
|
const { resetAuditLog } = useAuditLog()
|
||||||
|
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -27,6 +28,7 @@ onMounted(async () => {
|
|||||||
resetModules()
|
resetModules()
|
||||||
resetCurrentSite()
|
resetCurrentSite()
|
||||||
resetAuditLog()
|
resetAuditLog()
|
||||||
|
resetCategoriesAdmin()
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
|
|
||||||
drawer-class="w-full max-w-lg"
|
drawer-class="w-full max-w-lg"
|
||||||
|
header-class="border-b border-black"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">
|
||||||
|
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
:label="t('admin.sites.form.name')"
|
:label="t('admin.sites.form.name')"
|
||||||
@@ -59,41 +65,51 @@
|
|||||||
input-class="w-full font-mono"
|
input-class="w-full font-mono"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<!-- pb-4 sur le wrapper : simule le slot message du
|
||||||
|
MalioInputText voisin pour qu'items-center recentre
|
||||||
|
la puce sur le centre visible de l'input. -->
|
||||||
|
<div class="shrink-0 pb-4">
|
||||||
<span
|
<span
|
||||||
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
|
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
|
||||||
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
|
class="inline-block size-10 rounded-lg border border-neutral-200"
|
||||||
:class="{ 'border-dashed': !isValidHex }"
|
:class="{ 'border-dashed': !isValidHex }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
|
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
|
||||||
{{ t('admin.sites.form.colorInvalid') }}
|
{{ t('admin.sites.form.colorInvalid') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Boutons -->
|
</form>
|
||||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
|
||||||
|
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||||
|
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||||
|
<template #footer>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="isEditMode"
|
v-if="isEditMode"
|
||||||
:label="t('common.delete')"
|
:label="t('common.delete')"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
icon-name="mdi:delete-outline"
|
icon-name="mdi:delete-outline"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
@click="emit('delete')"
|
@click="emit('delete')"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-else
|
v-else
|
||||||
:label="t('common.cancel')"
|
:label="t('common.cancel')"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
@click="emit('update:modelValue', false)"
|
@click="emit('update:modelValue', false)"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.save')"
|
:label="t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
:disabled="saving || !isValidHex"
|
:disabled="saving || !isValidHex"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</form>
|
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- En-tete -->
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
|
||||||
{{ t('admin.sites.title') }}
|
{{ t('admin.sites.title') }}
|
||||||
</h1>
|
<template #actions>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="can('sites.manage')"
|
v-if="can('sites.manage')"
|
||||||
:label="t('admin.sites.newSite')"
|
:label="t('admin.sites.newSite')"
|
||||||
@@ -12,17 +10,22 @@
|
|||||||
icon-position="left"
|
icon-position="left"
|
||||||
@click="openCreateDrawer"
|
@click="openCreateDrawer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Table des sites -->
|
<!-- Table des sites — pagination serveur via usePaginatedList (#73). -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-6"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="siteItems"
|
:items="siteItems"
|
||||||
:total-items="sites.length"
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.sites.noSites')"
|
:empty-message="t('admin.sites.noSites')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
>
|
>
|
||||||
<template #cell-color="{ item }">
|
<template #cell-color="{ item }">
|
||||||
<span class="inline-flex items-center gap-2">
|
<span class="inline-flex items-center gap-2">
|
||||||
@@ -69,8 +72,20 @@ const canManage = computed(() => can('sites.manage'))
|
|||||||
|
|
||||||
useHead({ title: t('admin.sites.title') })
|
useHead({ title: t('admin.sites.title') })
|
||||||
|
|
||||||
const sites = ref<Site[]>([])
|
// Pagination serveur via le composable partage (#73). Aucun OrderFilter
|
||||||
const loading = ref(false)
|
// declare cote API Platform sur Site, donc on s'appuie sur le tri par
|
||||||
|
// defaut du repository (id ASC). Le composable est neanmoins pret a
|
||||||
|
// recevoir un `defaultSort` ou des filtres le jour ou l'API les expose.
|
||||||
|
const {
|
||||||
|
items: sites,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadSites,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
} = usePaginatedList<Site>({ url: '/sites' })
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: t('admin.sites.table.name') },
|
{ key: 'name', label: t('admin.sites.table.name') },
|
||||||
@@ -109,24 +124,6 @@ const deleteModalOpen = ref(false)
|
|||||||
const siteToDelete = ref<Site | null>(null)
|
const siteToDelete = ref<Site | null>(null)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
async function loadSites() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await api.get<{ member: Site[] }>(
|
|
||||||
'/sites',
|
|
||||||
{ itemsPerPage: 999 },
|
|
||||||
{ toast: false },
|
|
||||||
)
|
|
||||||
sites.value = data.member
|
|
||||||
} catch {
|
|
||||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
|
||||||
// requete reussie avant une perte reseau ou 403).
|
|
||||||
sites.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
function openCreateDrawer() {
|
||||||
selectedSite.value = null
|
selectedSite.value = null
|
||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
|
|||||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"name": "starseed-frontend",
|
"name": "starseed-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.5.0",
|
"@malio/layer-ui": "^1.7.3",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -1866,9 +1866,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.5.0",
|
"version": "1.7.3",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.5.0/layer-ui-1.5.0.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz",
|
||||||
"integrity": "sha512-uVuG8kRakWgpWYQCMUf1LFD+gjx0iRFfNJn/jlqjxiZmZyGZMckcMW2qA9hGZBiheBsTJWw1pRR4ufuyAYPY0A==",
|
"integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.5.0",
|
"@malio/layer-ui": "^1.7.3",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,51 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Entete de page standard : source unique du style des titres.
|
||||||
|
Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
|
||||||
|
<div class="mb-[44px] flex items-center justify-between gap-4">
|
||||||
|
<h1 class="text-[32px] font-semibold text-primary-500">
|
||||||
|
<slot/>
|
||||||
|
</h1>
|
||||||
|
<div v-if="$slots.actions" class="shrink-0">
|
||||||
|
<slot name="actions"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { usePaginatedList } from '../usePaginatedList'
|
||||||
|
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du composable `usePaginatedList`.
|
||||||
|
*
|
||||||
|
* Couvre les invariants critiques :
|
||||||
|
* - parse Hydra (member / totalItems)
|
||||||
|
* - navigation page (goToPage / next / prev / bornes)
|
||||||
|
* - changement items/page → retour page 1
|
||||||
|
* - mutation filtres / tri → retour page 1
|
||||||
|
* - cas limite : page courante hors borne apres filtre → derniere page valide
|
||||||
|
* - liste vide / page unique
|
||||||
|
* - reset → defaults
|
||||||
|
* - swallow d'erreur reseau (la promesse `fetch` ne reject jamais)
|
||||||
|
* - header `Accept: application/ld+json` toujours envoye (besoin du
|
||||||
|
* paginator Hydra cote API Platform 4).
|
||||||
|
*/
|
||||||
|
describe('usePaginatedList', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApiGet.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockResponse(member: unknown[], totalItems: number): void {
|
||||||
|
mockApiGet.mockResolvedValueOnce({ member, totalItems })
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fetch initial : page=1, itemsPerPage par defaut, parse Hydra', async () => {
|
||||||
|
mockResponse([{ id: 1 }, { id: 2 }], 42)
|
||||||
|
const list = usePaginatedList<{ id: number }>({ url: '/sites' })
|
||||||
|
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||||
|
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||||
|
expect(url).toBe('/sites')
|
||||||
|
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||||
|
expect(opts).toMatchObject({
|
||||||
|
toast: false,
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
})
|
||||||
|
expect(list.items.value).toEqual([{ id: 1 }, { id: 2 }])
|
||||||
|
expect(list.totalItems.value).toBe(42)
|
||||||
|
expect(list.totalPages.value).toBe(5)
|
||||||
|
expect(list.isEmpty.value).toBe(false)
|
||||||
|
expect(list.isSinglePage.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('itemsPerPage personnalise est respecte au premier fetch', async () => {
|
||||||
|
mockResponse([], 0)
|
||||||
|
const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 25 })
|
||||||
|
await list.fetch()
|
||||||
|
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ itemsPerPage: 25 })
|
||||||
|
expect(list.itemsPerPage.value).toBe(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goToPage(N) declenche un nouvel appel avec page=N', async () => {
|
||||||
|
mockResponse([{ id: 1 }], 30) // page 1
|
||||||
|
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([{ id: 2 }], 30) // page 2
|
||||||
|
await list.goToPage(2)
|
||||||
|
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2, itemsPerPage: 10 })
|
||||||
|
expect(list.currentPage.value).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goToPage hors borne sup. est clampe a totalPages', async () => {
|
||||||
|
mockResponse([], 30) // totalPages = 3
|
||||||
|
const list = usePaginatedList({ url: '/roles' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 30)
|
||||||
|
await list.goToPage(999)
|
||||||
|
|
||||||
|
expect(list.currentPage.value).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goToPage hors borne inf. est clampe a 1 (no-op si deja en 1)', async () => {
|
||||||
|
mockResponse([], 30)
|
||||||
|
const list = usePaginatedList({ url: '/roles' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockApiGet.mockClear()
|
||||||
|
await list.goToPage(-5)
|
||||||
|
|
||||||
|
// Deja en page 1 -> aucun nouvel appel.
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nextPage / prevPage avancent et reculent dans les bornes', async () => {
|
||||||
|
mockResponse([], 30) // page 1, totalPages 3
|
||||||
|
const list = usePaginatedList({ url: '/roles' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 30)
|
||||||
|
await list.nextPage()
|
||||||
|
expect(list.currentPage.value).toBe(2)
|
||||||
|
|
||||||
|
mockResponse([], 30)
|
||||||
|
await list.nextPage()
|
||||||
|
expect(list.currentPage.value).toBe(3)
|
||||||
|
|
||||||
|
// En derniere page -> no-op
|
||||||
|
mockApiGet.mockClear()
|
||||||
|
await list.nextPage()
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||||
|
expect(list.currentPage.value).toBe(3)
|
||||||
|
|
||||||
|
mockResponse([], 30)
|
||||||
|
await list.prevPage()
|
||||||
|
expect(list.currentPage.value).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setItemsPerPage revient en page 1 et refetch', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
// place-toi page 3
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.goToPage(3)
|
||||||
|
expect(list.currentPage.value).toBe(3)
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setItemsPerPage(25)
|
||||||
|
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
expect(list.itemsPerPage.value).toBe(25)
|
||||||
|
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setItemsPerPage no-op si meme valeur', async () => {
|
||||||
|
mockResponse([], 10)
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockApiGet.mockClear()
|
||||||
|
await list.setItemsPerPage(10)
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setFilters fusionne et retombe en page 1', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList<unknown, { name?: string; active?: boolean }>({
|
||||||
|
url: '/users',
|
||||||
|
defaultFilters: { active: true },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.goToPage(2)
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setFilters({ name: 'alice' })
|
||||||
|
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
expect(list.filters.value).toEqual({ active: true, name: 'alice' })
|
||||||
|
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||||
|
page: 1,
|
||||||
|
active: true,
|
||||||
|
name: 'alice',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setFilters({ key: undefined }) supprime la cle', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList<unknown, { name?: string }>({
|
||||||
|
url: '/users',
|
||||||
|
defaultFilters: { name: 'alice' },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setFilters({ name: undefined })
|
||||||
|
|
||||||
|
expect(list.filters.value).toEqual({})
|
||||||
|
// Le query envoye ne contient plus `name` (compactQuery elimine
|
||||||
|
// aussi les valeurs vides).
|
||||||
|
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||||
|
expect(q.name).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setFilters({ replace: true }) remplace integralement', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList<unknown, { a?: string; b?: string }>({
|
||||||
|
url: '/users',
|
||||||
|
defaultFilters: { a: 'x' },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setFilters({ b: 'y' }, { replace: true })
|
||||||
|
|
||||||
|
expect(list.filters.value).toEqual({ b: 'y' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setSort envoie order[field]=direction et reset page', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.goToPage(2)
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setSort({ field: 'username', direction: 'desc' })
|
||||||
|
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||||
|
expect(q['order[username]']).toBe('desc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setSort(null) retire le tri', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList({
|
||||||
|
url: '/users',
|
||||||
|
defaultSort: { field: 'name', direction: 'asc' },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
// Le tri initial est applique
|
||||||
|
let q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||||
|
expect(q['order[name]']).toBe('asc')
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setSort(null)
|
||||||
|
q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||||
|
expect(q['order[name]']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setFilters retombe en page 1 (cas standard, pas le hors-borne)', async () => {
|
||||||
|
// setFilters remet toujours page=1 avant de refetcher : ce n'est
|
||||||
|
// donc PAS le chemin de retry hors-borne (couvert par le test
|
||||||
|
// suivant via un refetch a page constante). On verifie juste le
|
||||||
|
// reset de page ici.
|
||||||
|
mockResponse([], 50) // 5 pages
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
mockResponse([], 50)
|
||||||
|
await list.goToPage(5)
|
||||||
|
expect(list.currentPage.value).toBe(5)
|
||||||
|
|
||||||
|
mockResponse([{ id: 1 }, { id: 2 }], 12)
|
||||||
|
await list.setFilters({ active: true } as never)
|
||||||
|
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => {
|
||||||
|
// Scenario : on a fait un fetch (5 pages, page=1). Sans toucher aux
|
||||||
|
// filtres mais entre deux fetchs la donnee a change cote serveur,
|
||||||
|
// la page courante peut devenir hors borne. On force le scenario
|
||||||
|
// en montant manuellement currentPage via goToPage borne, puis en
|
||||||
|
// simulant une reponse plus petite.
|
||||||
|
mockResponse([], 50) // 5 pages
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 50)
|
||||||
|
await list.goToPage(5)
|
||||||
|
expect(list.currentPage.value).toBe(5)
|
||||||
|
|
||||||
|
// Maintenant simule : refetch -> totalItems chute a 12 (2 pages),
|
||||||
|
// le composable doit refetcher sur page=2.
|
||||||
|
mockApiGet.mockReset()
|
||||||
|
mockApiGet
|
||||||
|
.mockResolvedValueOnce({ member: [], totalItems: 12 }) // page=5 vide
|
||||||
|
.mockResolvedValueOnce({ member: [{ id: 11 }, { id: 12 }], totalItems: 12 }) // page=2
|
||||||
|
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2 })
|
||||||
|
expect(list.currentPage.value).toBe(2)
|
||||||
|
expect(list.items.value).toEqual([{ id: 11 }, { id: 12 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liste vide : isEmpty true, isSinglePage true', async () => {
|
||||||
|
mockResponse([], 0)
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
expect(list.totalItems.value).toBe(0)
|
||||||
|
expect(list.isEmpty.value).toBe(true)
|
||||||
|
expect(list.isSinglePage.value).toBe(true)
|
||||||
|
expect(list.totalPages.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isEmpty est faux avant le premier fetch (etat indetermine)', () => {
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
expect(list.isEmpty.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reset revient aux defaults', async () => {
|
||||||
|
mockResponse([], 100)
|
||||||
|
const list = usePaginatedList<unknown, { a?: string }>({
|
||||||
|
url: '/users',
|
||||||
|
defaultItemsPerPage: 10,
|
||||||
|
defaultFilters: { a: 'x' },
|
||||||
|
defaultSort: { field: 'name', direction: 'asc' },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setItemsPerPage(50)
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setFilters({ a: 'y' })
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.setSort({ field: 'id', direction: 'desc' })
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.goToPage(2)
|
||||||
|
expect(list.currentPage.value).toBe(2)
|
||||||
|
|
||||||
|
mockResponse([], 100)
|
||||||
|
await list.reset()
|
||||||
|
|
||||||
|
expect(list.itemsPerPage.value).toBe(10)
|
||||||
|
expect(list.filters.value).toEqual({ a: 'x' })
|
||||||
|
expect(list.sort.value).toEqual({ field: 'name', direction: 'asc' })
|
||||||
|
expect(list.currentPage.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => {
|
||||||
|
const boom = new Error('boom')
|
||||||
|
mockApiGet.mockRejectedValueOnce(boom)
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
|
||||||
|
await expect(list.fetch()).resolves.toBeUndefined()
|
||||||
|
expect(list.items.value).toEqual([])
|
||||||
|
expect(list.totalItems.value).toBe(0)
|
||||||
|
expect(list.loading.value).toBe(false)
|
||||||
|
// L'erreur est consideree comme un fetch consume -> isEmpty=true.
|
||||||
|
expect(list.isEmpty.value).toBe(true)
|
||||||
|
// ... mais `error` est expose pour distinguer « vide » d'« echec ».
|
||||||
|
expect(list.error.value).toBe(boom)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('error est remis a null des qu\'un fetch ulterieur reussit', async () => {
|
||||||
|
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||||
|
const list = usePaginatedList({ url: '/users' })
|
||||||
|
await list.fetch()
|
||||||
|
expect(list.error.value).toBeInstanceOf(Error)
|
||||||
|
|
||||||
|
mockResponse([{ id: 1 }], 1)
|
||||||
|
await list.fetch()
|
||||||
|
expect(list.error.value).toBeNull()
|
||||||
|
expect(list.items.value).toEqual([{ id: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignore une reponse periemee : la derniere requete *demandee* gagne', async () => {
|
||||||
|
// Deux fetch concurrents : le 1er resout APRES le 2eme. Sans garde
|
||||||
|
// de sequence, la reponse arrivee en dernier (token 1) ecraserait
|
||||||
|
// les donnees plus fraiches du token 2. Avec la garde, token 2 fait
|
||||||
|
// foi quel que soit l'ordre d'arrivee reseau.
|
||||||
|
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||||
|
|
||||||
|
let resolveSlow!: (v: unknown) => void
|
||||||
|
const slow = new Promise((r) => { resolveSlow = r })
|
||||||
|
// 1er appel : reponse lente (en vol).
|
||||||
|
mockApiGet.mockReturnValueOnce(slow)
|
||||||
|
// 2eme appel : reponse immediate avec des donnees plus fraiches.
|
||||||
|
mockApiGet.mockResolvedValueOnce({ member: [{ id: 2 }], totalItems: 30 })
|
||||||
|
|
||||||
|
const p1 = list.fetch() // token 1, en vol
|
||||||
|
const p2 = list.fetch() // token 2, resout tout de suite
|
||||||
|
await p2
|
||||||
|
expect(list.items.value).toEqual([{ id: 2 }])
|
||||||
|
|
||||||
|
// La reponse lente du token 1 arrive enfin : elle doit etre ignoree.
|
||||||
|
resolveSlow({ member: [{ id: 1 }], totalItems: 30 })
|
||||||
|
await p1
|
||||||
|
expect(list.items.value).toEqual([{ id: 2 }])
|
||||||
|
// Le spinner reste eteint (la requete recente l'avait deja coupe).
|
||||||
|
expect(list.loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => {
|
||||||
|
mockResponse([], 0)
|
||||||
|
const list = usePaginatedList({
|
||||||
|
url: '/categories',
|
||||||
|
extraQuery: { includeDeleted: 'true' },
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ includeDeleted: 'true' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valeurs nulles/vides des filtres ne sont pas envoyees', async () => {
|
||||||
|
mockResponse([], 0)
|
||||||
|
const list = usePaginatedList<unknown, { name?: string; q?: string }>({
|
||||||
|
url: '/users',
|
||||||
|
defaultFilters: { name: '', q: undefined } as never,
|
||||||
|
})
|
||||||
|
await list.fetch()
|
||||||
|
|
||||||
|
const q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||||
|
expect(q.name).toBeUndefined()
|
||||||
|
expect(q.q).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refresh() est un alias de fetch()', async () => {
|
||||||
|
mockResponse([{ id: 1 }], 1)
|
||||||
|
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||||
|
|
||||||
|
await list.refresh()
|
||||||
|
expect(list.items.value).toEqual([{ id: 1 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Contrat (fige) :
|
||||||
|
// 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).
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
export interface CitySuggestion {
|
||||||
|
city: string
|
||||||
|
postalCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une suggestion d'adresse complete (saisie assistee du champ « Adresse »). */
|
||||||
|
export interface AddressSuggestion {
|
||||||
|
label: string
|
||||||
|
street: string
|
||||||
|
postalCode: string
|
||||||
|
city: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressAutocomplete {
|
||||||
|
searchCity(postalCode: string): Promise<CitySuggestion[]>
|
||||||
|
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erreur signalant que le service d'autocompletion 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.')
|
||||||
|
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 }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
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 {
|
||||||
|
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 ?? '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FetchOptions , FetchError } from 'ofetch'
|
import type { FetchOptions , FetchError } from 'ofetch'
|
||||||
import { $fetch } from 'ofetch'
|
import { $fetch } from 'ofetch'
|
||||||
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
export type AnyObject = Record<string, unknown>
|
export type AnyObject = Record<string, unknown>
|
||||||
|
|
||||||
@@ -41,24 +42,8 @@ export function useApi(): ApiClient {
|
|||||||
|
|
||||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||||
const data = responseData ?? (error as FetchError)?.data
|
const data = responseData ?? (error as FetchError)?.data
|
||||||
|
const msg = extractApiErrorMessage(data)
|
||||||
if (typeof data === 'string') {
|
if (msg) return msg
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data && typeof data === 'object') {
|
|
||||||
const record = data as Record<string, unknown>
|
|
||||||
return (
|
|
||||||
(record['hydra:description'] as string) ||
|
|
||||||
(record.detail as string) ||
|
|
||||||
(record.message as string) ||
|
|
||||||
(record.error as string) ||
|
|
||||||
(record.title as string) ||
|
|
||||||
(record['hydra:title'] as string) ||
|
|
||||||
''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { computed, ref, type Ref } from 'vue'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable generique de liste paginee serveur.
|
||||||
|
*
|
||||||
|
* Responsabilites :
|
||||||
|
* - centraliser l'etat tableau (page courante, items/page, tri, filtres,
|
||||||
|
* totalItems, items, loading, error) cote local — JAMAIS dans l'URL,
|
||||||
|
* conformement a la regle ABSOLUE n°6 du CLAUDE.md (« Jamais persister
|
||||||
|
* l'etat de tableau dans l'URL »).
|
||||||
|
* - dialoguer avec une ressource API Platform 4 (Hydra) en passant
|
||||||
|
* `page`, `itemsPerPage` et le tri/filtres en query params.
|
||||||
|
* - exposer une API simple a brancher sur `MalioDataTable`
|
||||||
|
* (props page/perPage/totalItems + events update:page / update:per-page).
|
||||||
|
*
|
||||||
|
* Volontairement **par-instance** (state local a chaque appel) : a la
|
||||||
|
* difference de `useAuditLog` / `useCategoriesAdmin` qui sont des
|
||||||
|
* singletons module-level partages, une liste paginee est propre a son
|
||||||
|
* ecran et ne doit pas etre partagee entre pages (sinon un retour
|
||||||
|
* arriere reprendrait la pagination d'une autre liste).
|
||||||
|
*
|
||||||
|
* Pas de gestion URL : si une page veut un deep link (ex : ouvrir un
|
||||||
|
* detail), elle le fait via sa propre route, pas via la query string
|
||||||
|
* de pagination. Derogation possible uniquement si l'utilisateur le
|
||||||
|
* demande explicitement, cf. CLAUDE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direction de tri serveur. API Platform 4 attend `asc` ou `desc` via la
|
||||||
|
* syntaxe `?order[field]=asc`.
|
||||||
|
*/
|
||||||
|
export type SortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specification de tri : un seul champ trie a la fois cote front (la
|
||||||
|
* majorite des tableaux Malio n'expose pas le multi-tri). Si null, aucun
|
||||||
|
* `order[...]` n'est envoye et l'API applique son tri par defaut.
|
||||||
|
*/
|
||||||
|
export interface SortSpec {
|
||||||
|
field: string
|
||||||
|
direction: SortDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type des filtres : un dictionnaire de valeurs serialisables en query
|
||||||
|
* params. Le caller decide du mapping (ex : `{ active: true }`,
|
||||||
|
* `{ 'name[ilike]': 'a' }`). Valeurs `null` / `undefined` / chaines vides
|
||||||
|
* sont automatiquement omises au moment de la requete.
|
||||||
|
*/
|
||||||
|
export type PaginatedListFilters = Record<string, string | number | boolean | string[] | null | undefined>
|
||||||
|
|
||||||
|
export interface UsePaginatedListOptions<F extends PaginatedListFilters = PaginatedListFilters> {
|
||||||
|
/** URL relative au prefix `/api` (ex : `/sites`, `/categories`). */
|
||||||
|
url: string
|
||||||
|
/** Items par page initial. Defaut 10 (aligne avec le defaut serveur). */
|
||||||
|
defaultItemsPerPage?: number
|
||||||
|
/** Options proposees dans le selecteur items/page. Defaut [10, 25, 50]. */
|
||||||
|
itemsPerPageOptions?: number[]
|
||||||
|
/** Filtres initiaux. */
|
||||||
|
defaultFilters?: F
|
||||||
|
/** Tri initial. */
|
||||||
|
defaultSort?: SortSpec | null
|
||||||
|
/**
|
||||||
|
* Query params additionnels propres a la ressource (ex : `includeDeleted=true`,
|
||||||
|
* `groups[]=foo`) injectes a chaque requete. **Snapshot statique** : l'objet
|
||||||
|
* est lu tel quel a chaque `fetch()`, ses valeurs ne sont pas deballees. Ne
|
||||||
|
* pas y passer de `ref` / `computed` (elles seraient serialisees comme objet,
|
||||||
|
* pas comme valeur) — pour un extra reactif, muter les filtres via
|
||||||
|
* `setFilters` ou ouvrir un ticket pour un support `MaybeRefOrGetter`.
|
||||||
|
*/
|
||||||
|
extraQuery?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePaginatedListReturn<T, F extends PaginatedListFilters = PaginatedListFilters> {
|
||||||
|
/** Items de la page courante. */
|
||||||
|
items: Ref<T[]>
|
||||||
|
/** Total d'items (toutes pages) renvoye par Hydra. */
|
||||||
|
totalItems: Ref<number>
|
||||||
|
/** Page courante (1-based). */
|
||||||
|
currentPage: Ref<number>
|
||||||
|
/** Taille de page courante. */
|
||||||
|
itemsPerPage: Ref<number>
|
||||||
|
/** Options exposees au selecteur items/page. */
|
||||||
|
itemsPerPageOptions: Ref<number[]>
|
||||||
|
/** Nombre total de pages (≥ 1). */
|
||||||
|
totalPages: Ref<number>
|
||||||
|
/** Indicateur de chargement (vrai pendant `fetch()`). */
|
||||||
|
loading: Ref<boolean>
|
||||||
|
/**
|
||||||
|
* Derniere erreur de `fetch()` (null si le dernier appel a abouti).
|
||||||
|
* Permet a la page de distinguer « liste reellement vide » d'un echec
|
||||||
|
* reseau / 403 : sans ca, `isEmpty` confond les deux cas (la liste
|
||||||
|
* tombe a 0 item dans les deux situations). La page decide de l'UX
|
||||||
|
* (bandeau, bouton reessayer) — le composable ne toaste pas.
|
||||||
|
*/
|
||||||
|
error: Ref<unknown | null>
|
||||||
|
/** Vrai apres au moins un fetch reussi avec 0 item. */
|
||||||
|
isEmpty: Ref<boolean>
|
||||||
|
/** Vrai si la collection tient en une seule page (totalPages <= 1). */
|
||||||
|
isSinglePage: Ref<boolean>
|
||||||
|
/** Filtres courants (mutation via `setFilters`). */
|
||||||
|
filters: Ref<F>
|
||||||
|
/** Tri courant (mutation via `setSort`). */
|
||||||
|
sort: Ref<SortSpec | null>
|
||||||
|
/** Lance un fetch contre l'API et met a jour items/totalItems. */
|
||||||
|
fetch: () => Promise<void>
|
||||||
|
/** Va a la page demandee (bornes appliquees : 1 ≤ p ≤ totalPages). */
|
||||||
|
goToPage: (page: number) => Promise<void>
|
||||||
|
/** Page suivante (no-op si deja en derniere page). */
|
||||||
|
nextPage: () => Promise<void>
|
||||||
|
/** Page precedente (no-op si deja en premiere page). */
|
||||||
|
prevPage: () => Promise<void>
|
||||||
|
/** Change la taille de page et revient en page 1. */
|
||||||
|
setItemsPerPage: (value: number) => Promise<void>
|
||||||
|
/** Applique de nouveaux filtres et revient en page 1. */
|
||||||
|
setFilters: (next: Partial<F>, options?: { replace?: boolean }) => Promise<void>
|
||||||
|
/** Change le tri et revient en page 1. */
|
||||||
|
setSort: (next: SortSpec | null) => Promise<void>
|
||||||
|
/** Reinitialise filtres + tri + page sur les valeurs par defaut. */
|
||||||
|
reset: () => Promise<void>
|
||||||
|
/** Alias de `fetch()` (intention plus claire dans certains contextes). */
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force `application/ld+json` : sous `application/json`, API Platform 4
|
||||||
|
* renvoie un tableau plat sans envelope de pagination — on ne pourrait pas
|
||||||
|
* lire `totalItems` ni `view`. Voir aussi `useAuditLog.ts`.
|
||||||
|
*/
|
||||||
|
const JSONLD_HEADERS = { Accept: 'application/ld+json' } as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtre les entrees nulles/undefined/vides d'un objet de query : evite
|
||||||
|
* d'envoyer `?foo=&bar=null` a l'API qui declencherait parfois des erreurs
|
||||||
|
* de filtre cote Symfony (`FilterInterface::apply` strict).
|
||||||
|
*/
|
||||||
|
function compactQuery(raw: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {}
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (value === null || value === undefined) continue
|
||||||
|
if (typeof value === 'string' && value === '') continue
|
||||||
|
if (Array.isArray(value) && value.length === 0) continue
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedListFilters>(
|
||||||
|
options: UsePaginatedListOptions<F>,
|
||||||
|
): UsePaginatedListReturn<T, F> {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const defaultItemsPerPage = options.defaultItemsPerPage ?? 10
|
||||||
|
const initialFilters = { ...(options.defaultFilters ?? ({} as F)) } as F
|
||||||
|
const initialSort = options.defaultSort ?? null
|
||||||
|
|
||||||
|
const items = ref<T[]>([]) as Ref<T[]>
|
||||||
|
const totalItems = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const itemsPerPage = ref(defaultItemsPerPage)
|
||||||
|
const itemsPerPageOptions = ref(options.itemsPerPageOptions ?? [10, 25, 50])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<unknown | null>(null)
|
||||||
|
// Jeton de sequence : incremente a chaque `fetch()`. Une reponse dont
|
||||||
|
// le jeton n'est plus le dernier est ignoree (protection contre les
|
||||||
|
// reponses periemes quand l'utilisateur enchaine page / tri / filtres
|
||||||
|
// plus vite que le reseau ne repond — sinon la derniere reponse
|
||||||
|
// *arrivee* gagnerait au lieu de la derniere *demandee*).
|
||||||
|
let fetchToken = 0
|
||||||
|
// `hasFetched` evite que `isEmpty` retourne `true` avant le premier
|
||||||
|
// chargement (etat initial = 0 items mais on ne sait pas encore si la
|
||||||
|
// ressource est vide ou en cours de chargement). Un appel reseau au
|
||||||
|
// moins doit avoir abouti pour qu'on annonce une liste « vide ».
|
||||||
|
const hasFetched = ref(false)
|
||||||
|
const filters = ref({ ...initialFilters }) as Ref<F>
|
||||||
|
const sort = ref<SortSpec | null>(initialSort ? { ...initialSort } : null)
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
if (totalItems.value <= 0 || itemsPerPage.value <= 0) return 1
|
||||||
|
return Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
|
||||||
|
})
|
||||||
|
const isEmpty = computed(() => hasFetched.value && totalItems.value === 0)
|
||||||
|
const isSinglePage = computed(() => totalPages.value <= 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'objet query envoye a l'API : extras + filtres, puis
|
||||||
|
* pagination + tri. Les cles reservees (`page`, `itemsPerPage`,
|
||||||
|
* `order[...]`) sont assignees **en dernier** pour qu'un filtre ou un
|
||||||
|
* extra portant le meme nom ne puisse pas ecraser silencieusement la
|
||||||
|
* pagination. Les filtres `null`/`undefined`/'' sont elimines pour ne
|
||||||
|
* pas polluer l'URL.
|
||||||
|
*/
|
||||||
|
function buildQuery(): Record<string, unknown> {
|
||||||
|
const query: Record<string, unknown> = {}
|
||||||
|
if (options.extraQuery) {
|
||||||
|
Object.assign(query, options.extraQuery)
|
||||||
|
}
|
||||||
|
Object.assign(query, filters.value)
|
||||||
|
// Cles reservees en dernier : priorite a la pagination/au tri.
|
||||||
|
query.page = currentPage.value
|
||||||
|
query.itemsPerPage = itemsPerPage.value
|
||||||
|
if (sort.value) {
|
||||||
|
// Format API Platform : ?order[field]=asc
|
||||||
|
query[`order[${sort.value.field}]`] = sort.value.direction
|
||||||
|
}
|
||||||
|
return compactQuery(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance un fetch et applique la borne haute si necessaire. Si la page
|
||||||
|
* courante depasse `totalPages` apres l'application des filtres (cas
|
||||||
|
* « j'etais en page 5, je filtre, il ne reste qu'une page »), on
|
||||||
|
* rappelle l'API sur la derniere page valide. Un seul niveau de retry
|
||||||
|
* pour eviter une boucle si l'API renvoie des resultats incoherents.
|
||||||
|
*/
|
||||||
|
async function fetch(): Promise<void> {
|
||||||
|
const token = ++fetchToken
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await api.get<HydraCollection<T>>(
|
||||||
|
options.url,
|
||||||
|
buildQuery(),
|
||||||
|
{ toast: false, headers: JSONLD_HEADERS },
|
||||||
|
)
|
||||||
|
// Une requete plus recente a ete lancee entre-temps : on jette
|
||||||
|
// cette reponse pour ne pas ecraser des donnees plus fraiches.
|
||||||
|
if (token !== fetchToken) return
|
||||||
|
items.value = data.member ?? []
|
||||||
|
totalItems.value = data.totalItems ?? 0
|
||||||
|
|
||||||
|
const tp = totalItems.value > 0
|
||||||
|
? Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
|
||||||
|
: 1
|
||||||
|
|
||||||
|
// Si on est hors borne ET qu'il y a au moins une page valide
|
||||||
|
// a viser, on retombe sur la derniere page (cf. cas limite
|
||||||
|
// « page hors borne apres filtre » de la spec #73). On ne
|
||||||
|
// refetch que si la nouvelle page est differente, sinon
|
||||||
|
// boucle infinie potentielle.
|
||||||
|
if (currentPage.value > tp && tp >= 1 && totalItems.value > 0) {
|
||||||
|
currentPage.value = tp
|
||||||
|
const data2 = await api.get<HydraCollection<T>>(
|
||||||
|
options.url,
|
||||||
|
buildQuery(),
|
||||||
|
{ toast: false, headers: JSONLD_HEADERS },
|
||||||
|
)
|
||||||
|
// Meme garde apres le refetch hors-borne.
|
||||||
|
if (token !== fetchToken) return
|
||||||
|
items.value = data2.member ?? []
|
||||||
|
totalItems.value = data2.totalItems ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFetched.value = true
|
||||||
|
} catch (e) {
|
||||||
|
// Reponse periemee : ne pas toucher au state, une requete plus
|
||||||
|
// recente est en cours et fera foi.
|
||||||
|
if (token !== fetchToken) return
|
||||||
|
// Swallow volontaire : on remet la liste a vide pour ne pas
|
||||||
|
// afficher de donnees stale, et on expose l'erreur pour que la
|
||||||
|
// page distingue « vide » d'« echec ». Le composant parent
|
||||||
|
// decide de l'UX (toast / message d'erreur) — pas d'a-priori ici.
|
||||||
|
error.value = e
|
||||||
|
items.value = []
|
||||||
|
totalItems.value = 0
|
||||||
|
hasFetched.value = true
|
||||||
|
} finally {
|
||||||
|
// Seule la requete la plus recente eteint le spinner : une
|
||||||
|
// reponse periemee ne doit pas le couper alors qu'un fetch plus
|
||||||
|
// recent est encore en vol.
|
||||||
|
if (token === fetchToken) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToPage(page: number): Promise<void> {
|
||||||
|
const tp = totalPages.value
|
||||||
|
const next = Math.max(1, Math.min(page, tp))
|
||||||
|
if (next === currentPage.value) return
|
||||||
|
currentPage.value = next
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextPage(): Promise<void> {
|
||||||
|
if (currentPage.value >= totalPages.value) return
|
||||||
|
await goToPage(currentPage.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prevPage(): Promise<void> {
|
||||||
|
if (currentPage.value <= 1) return
|
||||||
|
await goToPage(currentPage.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setItemsPerPage(value: number): Promise<void> {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return
|
||||||
|
const rounded = Math.floor(value)
|
||||||
|
if (rounded === itemsPerPage.value) return
|
||||||
|
itemsPerPage.value = rounded
|
||||||
|
currentPage.value = 1
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `replace: false` (defaut) fusionne avec les filtres courants. Une
|
||||||
|
* valeur explicitement `undefined` retire la cle (utile pour effacer
|
||||||
|
* un filtre depuis un champ controle). `replace: true` remplace
|
||||||
|
* integralement l'objet par `next`.
|
||||||
|
*/
|
||||||
|
async function setFilters(next: Partial<F>, opts?: { replace?: boolean }): Promise<void> {
|
||||||
|
if (opts?.replace) {
|
||||||
|
filters.value = { ...(next as F) }
|
||||||
|
} else {
|
||||||
|
const merged = { ...filters.value, ...next } as F
|
||||||
|
// Supprime les cles explicitement passees a undefined : sans ce
|
||||||
|
// nettoyage, l'objet `filters` accumulerait des cles fantomes.
|
||||||
|
for (const key of Object.keys(next)) {
|
||||||
|
if (next[key as keyof F] === undefined) {
|
||||||
|
delete (merged as Record<string, unknown>)[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filters.value = merged
|
||||||
|
}
|
||||||
|
currentPage.value = 1
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSort(next: SortSpec | null): Promise<void> {
|
||||||
|
sort.value = next ? { ...next } : null
|
||||||
|
currentPage.value = 1
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset(): Promise<void> {
|
||||||
|
filters.value = { ...initialFilters }
|
||||||
|
sort.value = initialSort ? { ...initialSort } : null
|
||||||
|
itemsPerPage.value = defaultItemsPerPage
|
||||||
|
currentPage.value = 1
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isEmpty,
|
||||||
|
isSinglePage,
|
||||||
|
filters,
|
||||||
|
sort,
|
||||||
|
fetch,
|
||||||
|
goToPage,
|
||||||
|
nextPage,
|
||||||
|
prevPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
setFilters,
|
||||||
|
setSort,
|
||||||
|
reset,
|
||||||
|
refresh: fetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,3 +43,12 @@ export interface EffectivePermission {
|
|||||||
module: string
|
module: string
|
||||||
sources: string[]
|
sources: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groupement de permissions par module pour l'affichage en accordeon.
|
||||||
|
* Construit cote consommateur a partir de la liste plate /api/permissions.
|
||||||
|
*/
|
||||||
|
export interface PermissionModule {
|
||||||
|
module: string
|
||||||
|
permissions: Permission[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { formatPhoneFR } from '../phone'
|
||||||
|
|
||||||
|
describe('formatPhoneFR', () => {
|
||||||
|
it('formate un numero 10 chiffres en XX XX XX XX XX', () => {
|
||||||
|
expect(formatPhoneFR('0612345678')).toBe('06 12 34 56 78')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolere une saisie deja pointee ou espacee', () => {
|
||||||
|
expect(formatPhoneFR('06.12.34.56.78')).toBe('06 12 34 56 78')
|
||||||
|
expect(formatPhoneFR('06 12 34 56 78')).toBe('06 12 34 56 78')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne une chaine vide pour une valeur vide ou nulle', () => {
|
||||||
|
expect(formatPhoneFR('')).toBe('')
|
||||||
|
expect(formatPhoneFR(null)).toBe('')
|
||||||
|
expect(formatPhoneFR(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -31,3 +31,62 @@ export interface HydraCollection<T> {
|
|||||||
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||||
return collection.member ?? []
|
return collection.member ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||||
|
* pointe le champ concerne, `message` est le libelle a afficher.
|
||||||
|
*/
|
||||||
|
export interface ApiViolation {
|
||||||
|
propertyPath: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait les violations d'un payload d'erreur 422 d'API Platform 4. Supporte
|
||||||
|
* les deux formats de negociation (`violations` ou `hydra:violations`) et
|
||||||
|
* renvoie un tableau vide si le payload n'en contient pas d'exploitables.
|
||||||
|
*
|
||||||
|
* Utilise par useCategoryForm et tout futur composable de formulaire qui
|
||||||
|
* doit mapper les violations serveur sur ses champs.
|
||||||
|
*/
|
||||||
|
export function extractApiViolations(data: unknown): ApiViolation[] {
|
||||||
|
if (!data || typeof data !== 'object') return []
|
||||||
|
const record = data as Record<string, unknown>
|
||||||
|
const raw = record.violations ?? record['hydra:violations']
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const out: ApiViolation[] = []
|
||||||
|
for (const v of raw) {
|
||||||
|
if (!v || typeof v !== 'object') continue
|
||||||
|
const obj = v as Record<string, unknown>
|
||||||
|
out.push({
|
||||||
|
propertyPath: String(obj.propertyPath ?? ''),
|
||||||
|
message: String(obj.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 :
|
||||||
|
* `hydra:description` → `detail` → `description` → `message` → `error` →
|
||||||
|
* `title` → `hydra:title`. Renvoie '' si rien d'exploitable.
|
||||||
|
*
|
||||||
|
* Si `data` est une string, la renvoie telle quelle (cas des erreurs
|
||||||
|
* Symfony en text/plain ou des messages bruts).
|
||||||
|
*/
|
||||||
|
export function extractApiErrorMessage(data: unknown): string {
|
||||||
|
if (typeof data === 'string') return data
|
||||||
|
if (!data || typeof data !== 'object') return ''
|
||||||
|
const record = data as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
(record['hydra:description'] as string)
|
||||||
|
?? (record.detail as string)
|
||||||
|
?? (record.description as string)
|
||||||
|
?? (record.message as string)
|
||||||
|
?? (record.error as string)
|
||||||
|
?? (record.title as string)
|
||||||
|
?? (record['hydra:title'] as string)
|
||||||
|
?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Formatage d'un numero de telephone francais en groupes de 2 chiffres
|
||||||
|
* (`XX XX XX XX XX`).
|
||||||
|
*
|
||||||
|
* Helper PARTAGE volontaire : les telephones sont presents un peu partout dans
|
||||||
|
* l'app (fiches clients, contacts, fournisseurs, prestataires...). Introduit ici
|
||||||
|
* comme util transverse stable plutot que duplique a chaque ecran. La signature
|
||||||
|
* `formatPhoneFR(value): string` est coordonnee avec ERP-66, qui pourra enrichir
|
||||||
|
* l'implementation (validation, indicatif international) sans casser les appelants.
|
||||||
|
*
|
||||||
|
* - Ne garde que les chiffres puis groupe par 2 (tolere une saisie deja espacee
|
||||||
|
* ou pointee, ex: `06.12.34.56.78` ou `0612345678`).
|
||||||
|
* - Retourne une chaine vide si la valeur est vide/nulle (cellule vide propre).
|
||||||
|
*/
|
||||||
|
export function formatPhoneFR(value: string | null | undefined): string {
|
||||||
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
|
if (digits.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groupe par paquets de 2 ; un dernier groupe impair reste tel quel.
|
||||||
|
return digits.match(/.{1,2}/g)?.join(' ') ?? digits
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export interface Persona {
|
|||||||
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
|
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
|
||||||
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
|
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
|
||||||
// la copie/i18n change.
|
// la copie/i18n change.
|
||||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log'>
|
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHARED_PASSWORD = 'e2e-secret'
|
const SHARED_PASSWORD = 'e2e-secret'
|
||||||
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
|
|||||||
password: SHARED_PASSWORD,
|
password: SHARED_PASSWORD,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'],
|
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||||
},
|
},
|
||||||
'user-full': {
|
'user-full': {
|
||||||
key: 'user-full',
|
key: 'user-full',
|
||||||
@@ -63,8 +63,20 @@ export const personas: Record<PersonaKey, Persona> = {
|
|||||||
'sites.view',
|
'sites.view',
|
||||||
'sites.manage',
|
'sites.manage',
|
||||||
'sites.bypass_scope',
|
'sites.bypass_scope',
|
||||||
|
'catalog.categories.view',
|
||||||
|
'catalog.categories.manage',
|
||||||
|
// Commercial — Repertoire clients (M1). Mappe ici sur le persona
|
||||||
|
// "tout" en attendant les vrais roles metier (bureau/compta/
|
||||||
|
// commerciale/usine) seedes par ERP-74. Pas de nouveau persona
|
||||||
|
// (regle ABSOLUE n°7). commercial.clients.view n'ajoute pas de lien
|
||||||
|
// dans la section Administration, donc expectedAdminLinks reste inchange.
|
||||||
|
'commercial.clients.view',
|
||||||
|
'commercial.clients.manage',
|
||||||
|
'commercial.clients.accounting.view',
|
||||||
|
'commercial.clients.accounting.manage',
|
||||||
|
'commercial.clients.archive',
|
||||||
],
|
],
|
||||||
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'],
|
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||||
},
|
},
|
||||||
'user-readonly': {
|
'user-readonly': {
|
||||||
key: 'user-readonly',
|
key: 'user-readonly',
|
||||||
@@ -109,4 +121,4 @@ export function getPersona(key: PersonaKey): Persona {
|
|||||||
return personas[key]
|
return personas[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'audit-log'] as const
|
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Locator, Page } from '@playwright/test'
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'audit-log'
|
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'categories' | 'audit-log'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin".
|
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin".
|
||||||
|
|||||||
@@ -198,14 +198,37 @@ migration-migrate:
|
|||||||
# doctrine:fixtures:load essaie de DELETE toutes les tables connues
|
# doctrine:fixtures:load essaie de DELETE toutes les tables connues
|
||||||
# via les mappings — si fake_site_aware_entity est mappe mais absent
|
# via les mappings — si fake_site_aware_entity est mappe mais absent
|
||||||
# en DB, le purger crash.
|
# en DB, le purger crash.
|
||||||
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
# 3. fixtures -> sync-permissions -> seed-rbac : fixtures:load purge la table
|
||||||
# donc sync doit passer apres.
|
# permission, donc sync doit passer apres. seed-rbac (matrice RBAC § 2.7)
|
||||||
|
# passe ensuite, car attachMatrix() exige les permissions en base. Les
|
||||||
|
# comptes demo sont crees par RbacDemoFixtures au load (sans la matrice,
|
||||||
|
# attachee ici). Cf. ERP-74.
|
||||||
|
# 4. recreation des index partiels uniques : schema:update drop les index
|
||||||
|
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
|
||||||
|
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
|
||||||
|
# ils disparaissent apres schema:update. On les recree par dbal:run-sql :
|
||||||
|
# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
|
||||||
|
# - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi
|
||||||
|
# les actifs (slug du nom), pilote RG-1.03/1.29.
|
||||||
|
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
|
||||||
|
# parmi actifs non archives/non supprimes (RG-1.16), tests ERP-55.
|
||||||
|
# Sans ces restores, les POST doublons remontent 201 au lieu de 409.
|
||||||
|
# 5. app:apply-column-comments : meme cause, schema:update drop les COMMENT
|
||||||
|
# ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte
|
||||||
|
# pas d'attribut options['comment']). On rejoue le catalogue partage
|
||||||
|
# `ColumnCommentsCatalog` pour conserver la documentation SQL exigee par
|
||||||
|
# le test architecture ColumnsHaveSqlCommentTest (ERP-67).
|
||||||
test-db-setup:
|
test-db-setup:
|
||||||
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
||||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||||
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
||||||
|
$(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments
|
||||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||||
|
$(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac
|
||||||
|
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||||
|
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
|
||||||
|
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||||
|
|
||||||
fixtures:
|
fixtures:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||||
@@ -215,6 +238,15 @@ fixtures:
|
|||||||
sync-permissions:
|
sync-permissions:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction app:sync-permissions
|
$(SYMFONY_CONSOLE) --no-interaction app:sync-permissions
|
||||||
|
|
||||||
|
# Seed RBAC metier : roles (bureau/compta/commerciale/usine) + matrice § 2.7
|
||||||
|
# (+ comptes demo en dev). Idempotent et NON destructif. A lancer APRES
|
||||||
|
# sync-permissions (attachMatrix exige les permissions en base). Les comptes
|
||||||
|
# demo dev sont deja crees par RbacDemoFixtures (make fixtures) ; ici on attache
|
||||||
|
# la matrice (les permissions etaient purgees au moment du load fixtures).
|
||||||
|
# En recette/prod, c'est cette commande (avec/sans --with-demo-users) qui seede.
|
||||||
|
seed-rbac:
|
||||||
|
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
|
||||||
|
|
||||||
# Attention, supprime votre bdd local
|
# Attention, supprime votre bdd local
|
||||||
db-reset:
|
db-reset:
|
||||||
$(DOCKER_COMPOSE) down -v
|
$(DOCKER_COMPOSE) down -v
|
||||||
@@ -224,6 +256,7 @@ db-reset:
|
|||||||
$(MAKE) migration-migrate
|
$(MAKE) migration-migrate
|
||||||
$(MAKE) fixtures
|
$(MAKE) fixtures
|
||||||
$(MAKE) sync-permissions
|
$(MAKE) sync-permissions
|
||||||
|
$(MAKE) seed-rbac
|
||||||
$(MAKE) test-db-setup
|
$(MAKE) test-db-setup
|
||||||
|
|
||||||
# Restart la bdd
|
# Restart la bdd
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-67 — Retrofit `COMMENT ON COLUMN` / `COMMENT ON TABLE` sur toutes les
|
||||||
|
* tables metier existantes.
|
||||||
|
*
|
||||||
|
* Postgres stocke la description dans `pg_description`. Les outils d'admin
|
||||||
|
* (DBeaver, DataGrip, pgAdmin) l'affichent automatiquement, ce qui evite de
|
||||||
|
* remonter au code Doctrine pour comprendre la semantique d'une colonne.
|
||||||
|
*
|
||||||
|
* Source unique : `ColumnCommentsCatalog::comments()`. Le meme catalogue est
|
||||||
|
* rejoue par `app:apply-column-comments` apres `doctrine:schema:update --force`
|
||||||
|
* en environnement de test (Doctrine ORM ne conservant pas les commentaires
|
||||||
|
* absents du mapping PHP).
|
||||||
|
*
|
||||||
|
* Convention :
|
||||||
|
* - Description en francais, ≤ 200 caracteres.
|
||||||
|
* - Semantique du champ + contraintes / lien RG si pertinent.
|
||||||
|
*
|
||||||
|
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
|
||||||
|
* Starseed n°11) car elle touche plusieurs modules. Les futures migrations
|
||||||
|
* applicatives devront poser leur propre `COMMENT ON COLUMN` au moment de
|
||||||
|
* creer leurs colonnes (cf. regle ABSOLUE n°12 + .claude/rules/backend.md).
|
||||||
|
*/
|
||||||
|
final class Version20260528120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-67 : retrofit COMMENT ON COLUMN/TABLE sur toutes les tables metier existantes.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Ne commente que les tables ET colonnes deja presentes a ce stade de la
|
||||||
|
// chaine de migrations. Les tables des modules crees plus tard (M1
|
||||||
|
// Commercial, 06-01) ET les colonnes ajoutees ensuite sur une table
|
||||||
|
// existante (ex: category.code, ERP-78 06-02) figurent desormais dans le
|
||||||
|
// catalogue partage mais n'existent pas encore ici : elles posent leur
|
||||||
|
// propre COMMENT dans leur migration dediee (regle ABSOLUE n°12). Garde-fou
|
||||||
|
// indispensable (table + colonne), sinon enrichir le catalogue casse ce
|
||||||
|
// retrofit avec un "relation/column X does not exist".
|
||||||
|
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
|
||||||
|
if (!$schema->hasTable($table)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbTable = $schema->getTable($table);
|
||||||
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
|
|
||||||
|
foreach ($entries as $column => $description) {
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dbTable->hasColumn($column)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
$quotedTable,
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
|
||||||
|
// Symetrie avec up() : on n'efface que les commentaires des tables
|
||||||
|
// presentes (les tables des modules ulterieurs sont gerees par leur
|
||||||
|
// propre migration).
|
||||||
|
if (!$schema->hasTable($table)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
|
foreach ($entries as $column => $_) {
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $quotedTable));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS NULL',
|
||||||
|
$quotedTable,
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M1 — Repertoire clients (ERP-53) : creation de toute la structure BDD du
|
||||||
|
* module Commercial (clients + sous-collections + referentiels comptables).
|
||||||
|
*
|
||||||
|
* Tables creees :
|
||||||
|
* - Referentiels comptables (statiques, seedes ici) : tva_mode, payment_delay,
|
||||||
|
* payment_type, bank.
|
||||||
|
* - Table principale : client (formulaire + Information + Comptabilite +
|
||||||
|
* archive + soft-delete + Timestampable/Blamable).
|
||||||
|
* - Sous-collections : client_category (M2M), client_contact (1:n),
|
||||||
|
* client_address (1:n), client_rib (1:n).
|
||||||
|
* - Jointures de client_address : client_address_site, client_address_contact,
|
||||||
|
* client_address_category.
|
||||||
|
*
|
||||||
|
* Seed `category_type` (extension M0) : DISTRIBUTEUR / COURTIER / SECTEUR /
|
||||||
|
* AUTRE, en `ON CONFLICT (code) DO NOTHING` (idempotent — la table peut deja
|
||||||
|
* porter des donnees en prod). En dev/test, les fixtures purgent et re-seedent
|
||||||
|
* ces 4 types (cf. CategoryTypeFixtures) ; ce seed migration couvre la prod ou
|
||||||
|
* les fixtures ne tournent pas.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
|
||||||
|
* `App\Module\Commercial\...` : avec plusieurs migrations_paths, Doctrine
|
||||||
|
* Migrations 3.x trie par FQCN alphabetique (AlphabeticalComparator → strcmp).
|
||||||
|
* Un namespace `App\Module\Commercial\...` trierait AVANT `DoctrineMigrations\...`
|
||||||
|
* et la migration s'executerait avant la creation de user/category/site sur
|
||||||
|
* base vide → echec des FK. Le namespace racine garantit l'ordre par timestamp.
|
||||||
|
*
|
||||||
|
* Style DDL aligne sur la migration M0 (Version20260527164000) plutot que sur
|
||||||
|
* le pseudo-SQL de la spec § 3.2 : `INT GENERATED BY DEFAULT AS IDENTITY` (et
|
||||||
|
* non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non TIMESTAMPTZ, car le
|
||||||
|
* `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que
|
||||||
|
* `schema:update` restera un no-op quand les entites arriveront (ticket ERP-54).
|
||||||
|
*
|
||||||
|
* Decision Q4 (29/05/2026) : unicite metier sur le NOM DE SOCIETE uniquement.
|
||||||
|
* Pas d'index unique sur siren ni email (RG-1.15 / RG-1.17 supprimees).
|
||||||
|
*
|
||||||
|
* Chaque colonne porte un `COMMENT ON COLUMN` (regle ABSOLUE n°12, garde-fou
|
||||||
|
* ColumnsHaveSqlCommentTest). Les tables n'etant pas encore mappees par l'ORM,
|
||||||
|
* ces commentaires survivent au `schema:update --force` du setup de test.
|
||||||
|
*/
|
||||||
|
final class Version20260601000000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-53 (M1) : tables client + sous-collections + referentiels comptables + seed category_type.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->createAccountingReferentials();
|
||||||
|
$this->createClientTable();
|
||||||
|
$this->createClientCategory();
|
||||||
|
$this->createClientContact();
|
||||||
|
$this->createClientAddress();
|
||||||
|
$this->createClientAddressJoinTables();
|
||||||
|
$this->createClientRib();
|
||||||
|
$this->seedCategoryTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Ordre inverse des dependances FK : on supprime d'abord les jointures
|
||||||
|
// et sous-collections, puis client, puis les referentiels.
|
||||||
|
$this->addSql('DROP TABLE client_address_category');
|
||||||
|
$this->addSql('DROP TABLE client_address_contact');
|
||||||
|
$this->addSql('DROP TABLE client_address_site');
|
||||||
|
$this->addSql('DROP TABLE client_rib');
|
||||||
|
$this->addSql('DROP TABLE client_address');
|
||||||
|
$this->addSql('DROP TABLE client_contact');
|
||||||
|
$this->addSql('DROP TABLE client_category');
|
||||||
|
$this->addSql('DROP TABLE client');
|
||||||
|
$this->addSql('DROP TABLE bank');
|
||||||
|
$this->addSql('DROP TABLE payment_type');
|
||||||
|
$this->addSql('DROP TABLE payment_delay');
|
||||||
|
$this->addSql('DROP TABLE tva_mode');
|
||||||
|
|
||||||
|
// Retire uniquement les 4 types seedes par cette migration ET restes
|
||||||
|
// orphelins (aucune `category` ne les reference). Sans la clause
|
||||||
|
// NOT EXISTS, le DELETE casse sur la FK RESTRICT category.category_type_id
|
||||||
|
// des qu'une categorie pointe sur l'un d'eux. Symetrique du
|
||||||
|
// `ON CONFLICT (code) DO NOTHING` du up() : on ne defait que ce qu'on a
|
||||||
|
// reellement cree et qui n'est pas reutilise.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM category_type
|
||||||
|
WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Referentiels comptables (4 tables statiques, memes colonnes)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createAccountingReferentials(): void
|
||||||
|
{
|
||||||
|
$referentials = [
|
||||||
|
'tva_mode' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
|
||||||
|
'payment_delay' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
|
||||||
|
'payment_type' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
|
||||||
|
'bank' => 'Referentiel des banques selectionnables pour le reglement par virement.',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($referentials as $table => $tableComment) {
|
||||||
|
$this->addSql(sprintf(<<<'SQL'
|
||||||
|
CREATE TABLE %s (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
code VARCHAR(30) NOT NULL,
|
||||||
|
label VARCHAR(120) NOT NULL,
|
||||||
|
position INT DEFAULT 0 NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
)
|
||||||
|
SQL, $table));
|
||||||
|
$this->addSql(sprintf('CREATE UNIQUE INDEX uq_%s_code ON %s (code)', $table, $table));
|
||||||
|
|
||||||
|
$this->comment($table, '_table', $tableComment);
|
||||||
|
$this->comment($table, 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment($table, 'code', 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.');
|
||||||
|
$this->comment($table, 'label', 'Libelle affichable (FR, ≤ 120 caracteres).');
|
||||||
|
$this->comment($table, 'position', 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed initial (cf. spec § 3.2). Tables fraichement creees donc vides :
|
||||||
|
// INSERT direct sans ON CONFLICT.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO tva_mode (code, label, position) VALUES
|
||||||
|
('FRANCE_VENTES', 'France (ventes)', 10),
|
||||||
|
('EXPORT_VENTES', 'Export (ventes)', 20),
|
||||||
|
('INTRACOM_VENTES', 'Intracom (ventes)', 30)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO payment_delay (code, label, position) VALUES
|
||||||
|
('J15', '15 jours', 10),
|
||||||
|
('J30', '30 jours', 20),
|
||||||
|
('A_RECEPTION', 'À réception', 30)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO payment_type (code, label, position) VALUES
|
||||||
|
('VIREMENT', 'Virement', 10),
|
||||||
|
('LCR', 'LCR', 20),
|
||||||
|
('NON_SOUMISE', 'Non soumise', 30),
|
||||||
|
('CHEQUE', 'Chèque', 40)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO bank (code, label, position) VALUES
|
||||||
|
('SG', 'Société Générale', 10),
|
||||||
|
('CIC', 'CIC', 20),
|
||||||
|
('CA', 'Crédit Agricole', 30)
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Table principale `client`
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientTable(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
company_name VARCHAR(180) NOT NULL,
|
||||||
|
first_name VARCHAR(120) DEFAULT NULL,
|
||||||
|
last_name VARCHAR(120) DEFAULT NULL,
|
||||||
|
phone_primary VARCHAR(20) NOT NULL,
|
||||||
|
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||||
|
email VARCHAR(180) NOT NULL,
|
||||||
|
distributor_id INT DEFAULT NULL,
|
||||||
|
broker_id INT DEFAULT NULL,
|
||||||
|
triage_service BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
description TEXT DEFAULT NULL,
|
||||||
|
competitors VARCHAR(255) DEFAULT NULL,
|
||||||
|
founded_at DATE DEFAULT NULL,
|
||||||
|
employees_count INT DEFAULT NULL,
|
||||||
|
revenue_amount NUMERIC(15, 2) DEFAULT NULL,
|
||||||
|
director_name VARCHAR(120) DEFAULT NULL,
|
||||||
|
profit_amount NUMERIC(15, 2) DEFAULT NULL,
|
||||||
|
siren VARCHAR(20) DEFAULT NULL,
|
||||||
|
account_number VARCHAR(40) DEFAULT NULL,
|
||||||
|
tva_mode_id INT DEFAULT NULL,
|
||||||
|
n_tva VARCHAR(40) DEFAULT NULL,
|
||||||
|
payment_delay_id INT DEFAULT NULL,
|
||||||
|
payment_type_id INT DEFAULT NULL,
|
||||||
|
bank_id INT DEFAULT NULL,
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||||
|
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT DEFAULT NULL,
|
||||||
|
updated_by INT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT chk_client_distrib_or_broker
|
||||||
|
CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)),
|
||||||
|
CONSTRAINT fk_client_distributor
|
||||||
|
FOREIGN KEY (distributor_id) REFERENCES client (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_broker
|
||||||
|
FOREIGN KEY (broker_id) REFERENCES client (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_tva_mode
|
||||||
|
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_client_payment_delay
|
||||||
|
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_client_payment_type
|
||||||
|
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_client_bank
|
||||||
|
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_client_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_updated_by
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql('CREATE INDEX idx_client_is_archived ON client (is_archived)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_deleted_at ON client (deleted_at)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_distributor_id ON client (distributor_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_broker_id ON client (broker_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_created_by ON client (created_by)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_updated_by ON client (updated_by)');
|
||||||
|
|
||||||
|
// Index sur les FK des referentiels comptables — coherence avec les autres
|
||||||
|
// FK deja indexees ci-dessus (Postgres n'indexe pas automatiquement les
|
||||||
|
// colonnes portant une FOREIGN KEY).
|
||||||
|
$this->addSql('CREATE INDEX idx_client_tva_mode_id ON client (tva_mode_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_payment_delay_id ON client (payment_delay_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_payment_type_id ON client (payment_type_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_client_bank_id ON client (bank_id)');
|
||||||
|
|
||||||
|
// Unicite metier partielle (Q4) : nom de societe insensible a la casse,
|
||||||
|
// parmi les non-archives ET non soft-deletes uniquement. Pas d'index
|
||||||
|
// unique sur siren ni email.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE UNIQUE INDEX uq_client_company_name_active
|
||||||
|
ON client (LOWER(company_name))
|
||||||
|
WHERE is_archived = FALSE AND deleted_at IS NULL
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->comment('client', '_table', 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).');
|
||||||
|
$this->comment('client', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('client', 'company_name', 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).');
|
||||||
|
$this->comment('client', 'first_name', 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
||||||
|
$this->comment('client', 'last_name', 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
||||||
|
$this->comment('client', 'phone_primary', 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.');
|
||||||
|
$this->comment('client', 'phone_secondary', 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).');
|
||||||
|
$this->comment('client', 'email', 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).');
|
||||||
|
$this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.');
|
||||||
|
$this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.');
|
||||||
|
$this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.');
|
||||||
|
$this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.');
|
||||||
|
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
||||||
|
$this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).');
|
||||||
|
$this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.');
|
||||||
|
$this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.');
|
||||||
|
$this->comment('client', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
|
||||||
|
$this->comment('client', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.');
|
||||||
|
$this->comment('client', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).');
|
||||||
|
$this->comment('client', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).');
|
||||||
|
$this->comment('client', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).');
|
||||||
|
$this->comment('client', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).');
|
||||||
|
$this->comment('client', 'deleted_at', 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.');
|
||||||
|
$this->addTimestampableBlamableComments('client');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// M2M client <-> category
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientCategory(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_category (
|
||||||
|
client_id INT NOT NULL,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (client_id, category_id),
|
||||||
|
CONSTRAINT fk_client_category_client
|
||||||
|
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_category_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_client_category_category ON client_category (category_id)');
|
||||||
|
|
||||||
|
$this->comment('client_category', '_table', 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).');
|
||||||
|
$this->comment('client_category', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.');
|
||||||
|
$this->comment('client_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Sous-collection : contacts (1:n)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientContact(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_contact (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
client_id INT NOT NULL,
|
||||||
|
first_name VARCHAR(120) DEFAULT NULL,
|
||||||
|
last_name VARCHAR(120) DEFAULT NULL,
|
||||||
|
job_title VARCHAR(120) DEFAULT NULL,
|
||||||
|
phone_primary VARCHAR(20) DEFAULT NULL,
|
||||||
|
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||||
|
email VARCHAR(180) DEFAULT NULL,
|
||||||
|
position INT DEFAULT 0 NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT DEFAULT NULL,
|
||||||
|
updated_by INT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT chk_client_contact_name
|
||||||
|
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL),
|
||||||
|
CONSTRAINT fk_client_contact_client
|
||||||
|
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_contact_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_contact_updated_by
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_client_contact_client ON client_contact (client_id)');
|
||||||
|
|
||||||
|
$this->comment('client_contact', '_table', 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).');
|
||||||
|
$this->comment('client_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('client_contact', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.');
|
||||||
|
$this->comment('client_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
||||||
|
$this->comment('client_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
||||||
|
$this->comment('client_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||||
|
$this->comment('client_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (RG-1.20).');
|
||||||
|
$this->comment('client_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).');
|
||||||
|
$this->comment('client_contact', 'email', 'Email du contact (lowercase serveur, RG-1.21).');
|
||||||
|
$this->comment('client_contact', 'position', 'Ordre d affichage du contact dans la liste du client (croissant).');
|
||||||
|
$this->addTimestampableBlamableComments('client_contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Sous-collection : adresses (1:n)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientAddress(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_address (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
client_id INT NOT NULL,
|
||||||
|
is_prospect BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
is_delivery BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
is_billing BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
country VARCHAR(80) DEFAULT 'France' NOT NULL,
|
||||||
|
postal_code VARCHAR(20) NOT NULL,
|
||||||
|
city VARCHAR(120) NOT NULL,
|
||||||
|
street VARCHAR(255) NOT NULL,
|
||||||
|
street_complement VARCHAR(255) DEFAULT NULL,
|
||||||
|
billing_email VARCHAR(180) DEFAULT NULL,
|
||||||
|
position INT DEFAULT 0 NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT DEFAULT NULL,
|
||||||
|
updated_by INT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT chk_client_address_prospect_exclusive
|
||||||
|
CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))),
|
||||||
|
CONSTRAINT chk_client_address_billing_email
|
||||||
|
CHECK ((is_billing = FALSE AND billing_email IS NULL)
|
||||||
|
OR (is_billing = TRUE AND billing_email IS NOT NULL)),
|
||||||
|
CONSTRAINT fk_client_address_client
|
||||||
|
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_address_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_address_updated_by
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_client_address_client ON client_address (client_id)');
|
||||||
|
|
||||||
|
$this->comment('client_address', '_table', 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).');
|
||||||
|
$this->comment('client_address', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('client_address', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.');
|
||||||
|
$this->comment('client_address', 'is_prospect', 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.');
|
||||||
|
$this->comment('client_address', 'is_delivery', 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.');
|
||||||
|
$this->comment('client_address', 'is_billing', 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.');
|
||||||
|
$this->comment('client_address', 'country', 'Pays de l adresse — defaut France.');
|
||||||
|
$this->comment('client_address', 'postal_code', 'Code postal (4-5 chiffres attendus, RG-1.09).');
|
||||||
|
$this->comment('client_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).');
|
||||||
|
$this->comment('client_address', 'street', 'Numero et voie de l adresse.');
|
||||||
|
$this->comment('client_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
|
||||||
|
$this->comment('client_address', 'billing_email', 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).');
|
||||||
|
$this->comment('client_address', 'position', 'Ordre d affichage de l adresse dans la liste du client (croissant).');
|
||||||
|
$this->addTimestampableBlamableComments('client_address');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Jointures de client_address (M2M)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientAddressJoinTables(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_address_site (
|
||||||
|
client_address_id INT NOT NULL,
|
||||||
|
site_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (client_address_id, site_id),
|
||||||
|
CONSTRAINT fk_client_address_site_address
|
||||||
|
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_address_site_site
|
||||||
|
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->comment('client_address_site', '_table', 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).');
|
||||||
|
$this->comment('client_address_site', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||||
|
$this->comment('client_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_address_contact (
|
||||||
|
client_address_id INT NOT NULL,
|
||||||
|
client_contact_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (client_address_id, client_contact_id),
|
||||||
|
CONSTRAINT fk_client_address_contact_address
|
||||||
|
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_address_contact_contact
|
||||||
|
FOREIGN KEY (client_contact_id) REFERENCES client_contact (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->comment('client_address_contact', '_table', 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.');
|
||||||
|
$this->comment('client_address_contact', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||||
|
$this->comment('client_address_contact', 'client_contact_id', 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_address_category (
|
||||||
|
client_address_id INT NOT NULL,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (client_address_id, category_id),
|
||||||
|
CONSTRAINT fk_client_address_category_address
|
||||||
|
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_address_category_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->comment('client_address_category', '_table', 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).');
|
||||||
|
$this->comment('client_address_category', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||||
|
$this->comment('client_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Sous-collection : RIB (1:n)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function createClientRib(): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE client_rib (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
client_id INT NOT NULL,
|
||||||
|
label VARCHAR(120) NOT NULL,
|
||||||
|
bic VARCHAR(20) NOT NULL,
|
||||||
|
iban VARCHAR(34) NOT NULL,
|
||||||
|
position INT DEFAULT 0 NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
created_by INT DEFAULT NULL,
|
||||||
|
updated_by INT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_client_rib_client
|
||||||
|
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_client_rib_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_client_rib_updated_by
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_client_rib_client ON client_rib (client_id)');
|
||||||
|
|
||||||
|
$this->comment('client_rib', '_table', 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).');
|
||||||
|
$this->comment('client_rib', 'id', 'Identifiant interne auto-incremente.');
|
||||||
|
$this->comment('client_rib', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.');
|
||||||
|
$this->comment('client_rib', 'label', 'Libelle du RIB (ex: compte principal).');
|
||||||
|
$this->comment('client_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
|
||||||
|
$this->comment('client_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
|
||||||
|
$this->comment('client_rib', 'position', 'Ordre d affichage du RIB dans la liste du client (croissant).');
|
||||||
|
$this->addTimestampableBlamableComments('client_rib');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Seed extension category_type (M0)
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private function seedCategoryTypes(): void
|
||||||
|
{
|
||||||
|
// Idempotent : la table category_type peut deja porter des donnees en
|
||||||
|
// prod. ON CONFLICT (code) s appuie sur l index unique uq_category_type_code.
|
||||||
|
// NB : la table M0 n a pas de colonne `position` (id/code/label seulement),
|
||||||
|
// contrairement au pseudo-SQL de la spec § 3.3.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_type (code, label) VALUES
|
||||||
|
('DISTRIBUTEUR', 'Distributeur'),
|
||||||
|
('COURTIER', 'Courtier'),
|
||||||
|
('SECTEUR', 'Secteur'),
|
||||||
|
('AUTRE', 'Autre')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Helpers
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||||
|
* en reutilisant le catalogue partage (source unique, cf. ERP-67).
|
||||||
|
*/
|
||||||
|
private function addTimestampableBlamableComments(string $table): void
|
||||||
|
{
|
||||||
|
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||||
|
$this->comment($table, $column, $description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
|
||||||
|
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
|
||||||
|
* tout echappement d apostrophe.
|
||||||
|
*/
|
||||||
|
private function comment(string $table, string $column, string $description): void
|
||||||
|
{
|
||||||
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
|
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
$quotedTable,
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use App\Shared\Infrastructure\Database\CategoryCodeSql;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-78 — Refonte de la taxonomie Categories (M0/M1).
|
||||||
|
*
|
||||||
|
* Modele AVANT (merge via Version20260527164000 + Version20260601000000) :
|
||||||
|
* DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE sont des `category_type`.
|
||||||
|
*
|
||||||
|
* Modele APRES (decision produit 01/06) :
|
||||||
|
* - UN SEUL `category_type` : CLIENT (code CLIENT, label « Client ») ;
|
||||||
|
* - Distributeur / Courtier / Secteur / Autre (+ categories metier fines)
|
||||||
|
* deviennent des `Category` rattachees au type CLIENT ;
|
||||||
|
* - filtrage metier sur un `code` stable porte par la `Category` (et non plus
|
||||||
|
* par le type) : on reporte les codes DISTRIBUTEUR / COURTIER sur la categorie
|
||||||
|
* correspondante. RG-1.03 (distributor/broker) et RG-1.29 (categorie interdite
|
||||||
|
* sur adresse) s'appuient desormais sur `category.code`.
|
||||||
|
*
|
||||||
|
* Migration CORRECTIVE et NOUVELLE : la migration mergee Version20260601000000
|
||||||
|
* (qui a pu tourner en CI / chez d'autres devs) n'est PAS editee.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire
|
||||||
|
* Catalog : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par
|
||||||
|
* FQCN alphabetique (AlphabeticalComparator). Introduire la 1re migration
|
||||||
|
* modulaire `App\Module\Catalog\...` la ferait trier AVANT toutes les
|
||||||
|
* `DoctrineMigrations\...` sur base vide -> elle s'executerait avant la creation
|
||||||
|
* des tables et le seed dont elle depend. Le namespace racine garantit l'ordre
|
||||||
|
* par timestamp.
|
||||||
|
*
|
||||||
|
* Idempotence : `ADD COLUMN IF NOT EXISTS`, `INSERT ... ON CONFLICT` / guards
|
||||||
|
* `NOT EXISTS`, `CREATE UNIQUE INDEX IF NOT EXISTS`. En prod la table `category`
|
||||||
|
* est vide (aucune fixture metier) : l'ajout de `code NOT NULL` est sur. En
|
||||||
|
* dev/test, le purger Doctrine vide `category`/`category_type` avant les
|
||||||
|
* fixtures, qui reproduisent le meme etat final (cf. CategoryTypeFixtures /
|
||||||
|
* CategoryFixtures).
|
||||||
|
*/
|
||||||
|
final class Version20260602100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Categories systeme reportees depuis les anciens types : nom => code.
|
||||||
|
* Le code est la cle metier stable (RG-1.03 / RG-1.29).
|
||||||
|
*/
|
||||||
|
private const array SYSTEM_CATEGORIES = [
|
||||||
|
'Distributeur' => 'DISTRIBUTEUR',
|
||||||
|
'Courtier' => 'COURTIER',
|
||||||
|
'Secteur' => 'SECTEUR',
|
||||||
|
'Autre' => 'AUTRE',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Anciens codes de `category_type` devenus inutiles. */
|
||||||
|
private const array LEGACY_TYPE_CODES = ['DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE'];
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-78 : Category.code + type unique CLIENT (categories Distributeur/Courtier/Secteur/Autre codees, anciens types supprimes).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1. Colonne `code` (nullable d'abord pour pouvoir backfiller, NOT NULL ensuite).
|
||||||
|
$this->addSql('ALTER TABLE category ADD COLUMN IF NOT EXISTS code VARCHAR(50) DEFAULT NULL');
|
||||||
|
|
||||||
|
// 2. Type unique CLIENT (idempotent via l'index unique uq_category_type_code).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_type (code, label) VALUES ('CLIENT', 'Client')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 3. Re-pointer toute categorie pre-existante (rattachee a un ancien type)
|
||||||
|
// vers le type CLIENT, en lui donnant un code derive du nom si absent.
|
||||||
|
// En prod la table est vide -> no-op ; defensif pour les envs qui
|
||||||
|
// auraient deja seede des categories sous les anciens types. Le slug
|
||||||
|
// SQL est le miroir EXACT de CategoryCodeGenerator::slugify (cf.
|
||||||
|
// CategoryCodeSql + CategoryCodeSqlSlugTest) : un nom accentue produit
|
||||||
|
// le meme code que la generation applicative (« Independant » ->
|
||||||
|
// INDEPENDANT, et non IND_PENDANT).
|
||||||
|
$this->addSql(
|
||||||
|
'UPDATE category c '
|
||||||
|
."SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'), "
|
||||||
|
.'code = COALESCE(c.code, '.CategoryCodeSql::slugExpression('c.name').') '
|
||||||
|
.'WHERE c.category_type_id IN (SELECT id FROM category_type WHERE code IN (:legacyCodes))',
|
||||||
|
['legacyCodes' => self::LEGACY_TYPE_CODES],
|
||||||
|
['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Creer les 4 categories systeme sous CLIENT (si leur code est libre
|
||||||
|
// parmi les actifs). created_at/updated_at NOT NULL -> now() ; le blame
|
||||||
|
// reste null (seed hors contexte HTTP, libelle « Systeme » cote front).
|
||||||
|
foreach (self::SYSTEM_CATEGORIES as $name => $code) {
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category (name, code, category_type_id, created_at, updated_at)
|
||||||
|
SELECT :name, :code, ct.id, NOW(), NOW()
|
||||||
|
FROM category_type ct
|
||||||
|
WHERE ct.code = 'CLIENT'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SQL, ['name' => $name, 'code' => $code]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Backfill defensif : toute categorie encore sans code recoit un slug
|
||||||
|
// de son nom (garantit que le SET NOT NULL passe). Meme expression de
|
||||||
|
// slug fidele au generateur applicatif (CategoryCodeSql).
|
||||||
|
$this->addSql(
|
||||||
|
'UPDATE category SET code = '.CategoryCodeSql::slugExpression('name').' WHERE code IS NULL',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Index unique partiel sur le code parmi les actifs (non exprimable en
|
||||||
|
// ORM : recree aussi dans `test-db-setup` apres schema:update).
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL');
|
||||||
|
|
||||||
|
// 7. Code desormais obligatoire.
|
||||||
|
$this->addSql('ALTER TABLE category ALTER COLUMN code SET NOT NULL');
|
||||||
|
|
||||||
|
// 8. Documentation SQL (regle ABSOLUE n°12). Dollar-quoting Postgres.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
COMMENT ON COLUMN category.code IS $_$Code technique stable (slug MAJUSCULE du nom, <= 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.$_$
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 9. Supprimer les anciens types devenus orphelins (aucune categorie ne
|
||||||
|
// les reference plus apres le re-pointage de l'etape 3). Le guard
|
||||||
|
// NOT EXISTS evite de casser sur la FK RESTRICT category.category_type_id.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM category_type
|
||||||
|
WHERE code IN (:legacyCodes)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
|
||||||
|
)
|
||||||
|
SQL, ['legacyCodes' => self::LEGACY_TYPE_CODES], ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING]);
|
||||||
|
|
||||||
|
// 10. Realigner la doc SQL de client_address_category (migration mergee
|
||||||
|
// Version20260601000000, non editable) sur le nouveau modele RG-1.29.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
COMMENT ON TABLE client_address_category IS $_$Jointure M2M client_address <-> category — codes DISTRIBUTEUR/COURTIER interdits sur une adresse (RG-1.29).$_$
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
COMMENT ON COLUMN client_address_category.category_id IS $_$FK -> category.id, ON DELETE RESTRICT — categorie d adresse (tout code sauf DISTRIBUTEUR/COURTIER, RG-1.29).$_$
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Best-effort : rollback du modele CLIENT vers les 4 anciens types.
|
||||||
|
// 1. Retirer l'index unique sur le code.
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS uq_category_code');
|
||||||
|
|
||||||
|
// 2. Recreer les 4 anciens types.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_type (code, label) VALUES
|
||||||
|
('DISTRIBUTEUR', 'Distributeur'),
|
||||||
|
('COURTIER', 'Courtier'),
|
||||||
|
('SECTEUR', 'Secteur'),
|
||||||
|
('AUTRE', 'Autre')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 3. Re-pointer les categories systeme (par code) vers leur type d'origine.
|
||||||
|
// Codes inlines : constantes controlees (self::SYSTEM_CATEGORIES), pas
|
||||||
|
// d'entree utilisateur — evite le binding d'un parametre nomme repete.
|
||||||
|
foreach (self::SYSTEM_CATEGORIES as $name => $code) {
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
"UPDATE category SET category_type_id = (SELECT id FROM category_type WHERE code = '%s') WHERE code = '%s'",
|
||||||
|
$code,
|
||||||
|
$code,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Supprimer le type CLIENT s'il ne reference plus aucune categorie.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM category_type
|
||||||
|
WHERE code = 'CLIENT'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 5. Retirer la colonne code (les categories libres sans type d'origine
|
||||||
|
// restent sous CLIENT si encore presentes — rollback uniquement
|
||||||
|
// pertinent en prod ou seules les 4 categories systeme existent).
|
||||||
|
$this->addSql('ALTER TABLE category DROP COLUMN IF EXISTS code');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M1 — Suppression du contact principal inline du `Client` (refonte contact).
|
||||||
|
*
|
||||||
|
* Modele AVANT : le `Client` portait 5 colonnes de contact principal
|
||||||
|
* (first_name, last_name, phone_primary, phone_secondary, email) en doublon
|
||||||
|
* conceptuel de la sous-entite `ClientContact` (onglet Contact).
|
||||||
|
*
|
||||||
|
* Modele APRES (decision produit, README refonte-contact) : les contacts vivent
|
||||||
|
* UNIQUEMENT dans `client_contact`. Les 5 colonnes inline disparaissent du
|
||||||
|
* `client`. RG-1.01 (firstName OU lastName sur Client) et RG-1.02 (max 2
|
||||||
|
* telephones sur Client) sont supprimees : leur equivalent vit deja sur
|
||||||
|
* `client_contact` (RG-1.05 / RG-1.14).
|
||||||
|
*
|
||||||
|
* Le code etant deja en prod, la suppression est precedee d'un BACKFILL : pour
|
||||||
|
* tout client n'ayant encore AUCUN contact, on materialise son contact principal
|
||||||
|
* inline en une ligne `client_contact` (position 0) avant le DROP, afin de ne
|
||||||
|
* perdre aucune donnee.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire
|
||||||
|
* Commercial : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par
|
||||||
|
* FQCN alphabetique (AlphabeticalComparator). Une migration
|
||||||
|
* `App\Module\Commercial\...` trierait AVANT toutes les `DoctrineMigrations\...`
|
||||||
|
* sur base vide -> ce DROP s'executerait avant le CREATE TABLE client
|
||||||
|
* (Version20260601000000). Le namespace racine garantit l'ordre par timestamp.
|
||||||
|
*/
|
||||||
|
final class Version20260603120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
/** Colonnes de contact inline supprimees du `client`. */
|
||||||
|
private const array INLINE_CONTACT_COLUMNS = [
|
||||||
|
'first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'M1 : suppression du contact inline du Client (backfill vers client_contact puis DROP des 5 colonnes).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1. Backfill : tout client SANS contact recoit une ligne client_contact
|
||||||
|
// (position 0) reprenant ses champs inline. phone_primary / email du
|
||||||
|
// client sont NOT NULL -> toujours une donnee a reporter. Le CHECK
|
||||||
|
// chk_client_contact_name (first_name OU last_name) est garanti par le
|
||||||
|
// fallback company_name si jamais les deux noms etaient null (cas qui ne
|
||||||
|
// devrait pas exister, RG-1.01 ayant ete appliquee a l'ecriture).
|
||||||
|
// created_at/updated_at NOT NULL -> NOW() ; created_by/updated_by null
|
||||||
|
// (backfill hors contexte HTTP, libelle « Systeme » cote front).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO client_contact (
|
||||||
|
client_id, first_name, last_name, phone_primary, phone_secondary,
|
||||||
|
email, position, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.first_name,
|
||||||
|
CASE
|
||||||
|
WHEN c.first_name IS NULL AND c.last_name IS NULL THEN c.company_name
|
||||||
|
ELSE c.last_name
|
||||||
|
END,
|
||||||
|
c.phone_primary,
|
||||||
|
c.phone_secondary,
|
||||||
|
c.email,
|
||||||
|
0,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM client c
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 2. DROP des 5 colonnes inline (rien a documenter : suppression).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE client
|
||||||
|
DROP COLUMN first_name,
|
||||||
|
DROP COLUMN last_name,
|
||||||
|
DROP COLUMN phone_primary,
|
||||||
|
DROP COLUMN phone_secondary,
|
||||||
|
DROP COLUMN email
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Best-effort : on RECREE les 5 colonnes (en NULLABLE — l'etat NOT NULL
|
||||||
|
// d'origine de phone_primary/email ne peut etre restaure sur une table
|
||||||
|
// peuplee sans risque) et on retro-alimente depuis le contact principal
|
||||||
|
// (position minimale) de chaque client. La donnee n'est pas garantie
|
||||||
|
// identique a l'origine : ce down() sert au rollback technique, pas a une
|
||||||
|
// restauration fidele.
|
||||||
|
$this->addSql('ALTER TABLE client ADD COLUMN first_name VARCHAR(120) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE client ADD COLUMN last_name VARCHAR(120) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE client ADD COLUMN phone_primary VARCHAR(20) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE client ADD COLUMN phone_secondary VARCHAR(20) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE client ADD COLUMN email VARCHAR(180) DEFAULT NULL');
|
||||||
|
|
||||||
|
// Retro-alimentation depuis le contact de position la plus basse.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE client c SET
|
||||||
|
first_name = cc.first_name,
|
||||||
|
last_name = cc.last_name,
|
||||||
|
phone_primary = cc.phone_primary,
|
||||||
|
phone_secondary = cc.phone_secondary,
|
||||||
|
email = cc.email
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (client_id)
|
||||||
|
client_id, first_name, last_name, phone_primary, phone_secondary, email
|
||||||
|
FROM client_contact
|
||||||
|
ORDER BY client_id, position ASC, id ASC
|
||||||
|
) cc
|
||||||
|
WHERE cc.client_id = c.id
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Re-pose des commentaires d'origine (regle ABSOLUE n°12) — dollar-quoting
|
||||||
|
// Postgres pour eviter tout echappement d apostrophe.
|
||||||
|
$this->addSql('COMMENT ON COLUMN client.first_name IS $_$Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN client.last_name IS $_$Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN client.phone_primary IS $_$Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN client.phone_secondary IS $_$Telephone secondaire optionnel — chiffres uniquement (RG-1.20).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN client.email IS $_$Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).$_$');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Application\Service;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||||
|
use Symfony\Component\String\Slugger\AsciiSlugger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genere le code technique stable d'une Category a partir de son nom (ERP-78).
|
||||||
|
*
|
||||||
|
* Regle (decision produit 02/06) : `code` est obligatoire et auto-genere — un
|
||||||
|
* slug MAJUSCULE du nom, sans accent, separateurs non alphanumeriques reduits a
|
||||||
|
* `_`, borne a 50 caracteres (longueur colonne). Exemples :
|
||||||
|
* - « Distributeur » -> DISTRIBUTEUR
|
||||||
|
* - « Agro-alimentaire » -> AGRO_ALIMENTAIRE
|
||||||
|
* - « Transport/Logistique » -> TRANSPORT_LOGISTIQUE
|
||||||
|
*
|
||||||
|
* Le code est FIGE a la creation (jamais recalcule sur renommage) afin de rester
|
||||||
|
* une cle deterministe stable entre environnements (RG-1.03 / RG-1.29 cote M1).
|
||||||
|
*
|
||||||
|
* Unicite : l'index partiel `uq_category_code` (WHERE deleted_at IS NULL) impose
|
||||||
|
* l'unicite parmi les categories actives. Deux noms distincts peuvent produire
|
||||||
|
* le meme slug (« Agro alimentaire » / « Agro-alimentaire ») : on suffixe alors
|
||||||
|
* le code par `_2`, `_3`... jusqu'a obtenir un code libre.
|
||||||
|
*/
|
||||||
|
final class CategoryCodeGenerator
|
||||||
|
{
|
||||||
|
/** Longueur maximale de la colonne `category.code`. */
|
||||||
|
private const int MAX_LENGTH = 50;
|
||||||
|
|
||||||
|
private readonly AsciiSlugger $slugger;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CategoryRepositoryInterface $categoryRepository,
|
||||||
|
) {
|
||||||
|
$this->slugger = new AsciiSlugger();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug brut (sans garantie d'unicite) — utile pour les seeds deterministes.
|
||||||
|
*/
|
||||||
|
public function slugify(string $name): string
|
||||||
|
{
|
||||||
|
$slug = $this->slugger->slug($name, '_')->upper()->toString();
|
||||||
|
|
||||||
|
// Borne a la longueur colonne, puis retire un eventuel `_` terminal
|
||||||
|
// introduit par la troncature.
|
||||||
|
$slug = substr($slug, 0, self::MAX_LENGTH);
|
||||||
|
$slug = trim($slug, '_');
|
||||||
|
|
||||||
|
// Garde-fou : un nom uniquement compose de caracteres non alphanumeriques
|
||||||
|
// (theorique, le nom est NotBlank + Length>=2) donnerait un slug vide.
|
||||||
|
return '' === $slug ? 'CATEGORY' : $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code unique parmi les categories actives : slug du nom, suffixe `_N` en
|
||||||
|
* cas de collision. `$excludeId` ignore la categorie courante (PATCH).
|
||||||
|
*/
|
||||||
|
public function generateUnique(string $name, ?int $excludeId = null): string
|
||||||
|
{
|
||||||
|
$base = $this->slugify($name);
|
||||||
|
$candidate = $base;
|
||||||
|
$suffix = 2;
|
||||||
|
|
||||||
|
while ($this->categoryRepository->existsActiveByCode($candidate, $excludeId)) {
|
||||||
|
$suffixStr = '_'.$suffix;
|
||||||
|
// Retronque la base pour que `base + suffixe` tienne dans 50 caracteres.
|
||||||
|
$candidate = substr($base, 0, self::MAX_LENGTH - strlen($suffixStr)).$suffixStr;
|
||||||
|
++$suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog;
|
||||||
|
|
||||||
|
final class CatalogModule
|
||||||
|
{
|
||||||
|
public const string ID = 'catalog';
|
||||||
|
public const string LABEL = 'Catalogue';
|
||||||
|
// REQUIRED = true : Category sera FK NOT NULL cote futurs modules Tiers
|
||||||
|
// (M-Clients, M-Fournisseurs, M-Prestataires). Desactiver Catalog casserait
|
||||||
|
// tout le metier au boot Doctrine. Cf. review Tristan MR #12 + spec M0 § 2.1.
|
||||||
|
public const bool REQUIRED = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste declarative des permissions RBAC exposees par le module Catalog.
|
||||||
|
*
|
||||||
|
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
|
||||||
|
* qui se charge d'upserter ces entrees dans la table `permission`, de
|
||||||
|
* reactiver les codes precedemment marques orphelins et de marquer comme
|
||||||
|
* orphelins ceux qui ont disparu du code source.
|
||||||
|
*
|
||||||
|
* La cle `module` est auto-injectee par le sync command a partir de
|
||||||
|
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
|
||||||
|
*
|
||||||
|
* Convention de nommage des codes : `module.resource[.sub].action` en
|
||||||
|
* snake_case, le prefixe module devant correspondre exactement a
|
||||||
|
* `self::ID` (verifie par la commande de synchronisation).
|
||||||
|
*
|
||||||
|
* Granularite alignee sur Core (view + manage), pas view/create/edit/delete
|
||||||
|
* (cf. spec M0 § 2.7).
|
||||||
|
*
|
||||||
|
* @return array<int, array{code: string, label: string}>
|
||||||
|
*/
|
||||||
|
public static function permissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
|
||||||
|
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'],
|
||||||
|
// Lecture-referentiel transverse (ERP-102) : permet de LISTER les categories
|
||||||
|
// pour alimenter les selects des modules Tiers (clients, fournisseurs...),
|
||||||
|
// sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue
|
||||||
|
// dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
|
||||||
|
['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Domain\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\CategoryProcessor;
|
||||||
|
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvider;
|
||||||
|
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\CategoryInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorie : referentiel metier classifiant les futurs tiers (clients,
|
||||||
|
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
|
||||||
|
* (FK vers le referentiel statique CategoryType).
|
||||||
|
*
|
||||||
|
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
|
||||||
|
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
|
||||||
|
* - Timestampable + Blamable via le Trait Shared : les 4 colonnes
|
||||||
|
* created_at / updated_at / created_by / updated_by sont remplies
|
||||||
|
* automatiquement par le TimestampableBlamableSubscriber.
|
||||||
|
* - `#[Auditable]` : chaque create / update / delete (soft) est trace dans
|
||||||
|
* audit_log par l'AuditListener du module Core.
|
||||||
|
*
|
||||||
|
* Provider (filtre soft-delete + ?includeDeleted + tri name ASC + 404 sur
|
||||||
|
* soft-deleted) et Processor (trim, 409 sur doublon, soft delete) branches
|
||||||
|
* au ticket 0.3 (ERP-45).
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
// Lecture (liste + item) : permission d'administration `view` OU permission
|
||||||
|
// de lecture-referentiel transverse `read_ref` (ERP-102). Les referentiels
|
||||||
|
// categories sont consommes par les modules Tiers (selects creation/filtre
|
||||||
|
// client) : tout role qui gere des tiers doit pouvoir les lire sans porter
|
||||||
|
// l'acces admin du Catalogue. `read_ref` est une permission Catalog (pas un
|
||||||
|
// code d'un autre module) -> isolement inter-module preserve.
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')",
|
||||||
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
|
provider: CategoryProvider::class,
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')",
|
||||||
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
|
provider: CategoryProvider::class,
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('catalog.categories.manage')",
|
||||||
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
|
denormalizationContext: ['groups' => ['category:write']],
|
||||||
|
processor: CategoryProcessor::class,
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
security: "is_granted('catalog.categories.manage')",
|
||||||
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
|
denormalizationContext: ['groups' => ['category:write']],
|
||||||
|
provider: CategoryProvider::class,
|
||||||
|
processor: CategoryProcessor::class,
|
||||||
|
),
|
||||||
|
new Delete(
|
||||||
|
security: "is_granted('catalog.categories.manage')",
|
||||||
|
provider: CategoryProvider::class,
|
||||||
|
processor: CategoryProcessor::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
|
||||||
|
#[ORM\Table(name: 'category')]
|
||||||
|
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index
|
||||||
|
// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id
|
||||||
|
// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL)
|
||||||
|
// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un
|
||||||
|
// index partiel via attribut.
|
||||||
|
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
|
||||||
|
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
|
||||||
|
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
|
||||||
|
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
||||||
|
#[Auditable]
|
||||||
|
class Category implements TimestampableInterface, BlamableInterface, CategoryInterface
|
||||||
|
{
|
||||||
|
// === Timestampable + Blamable ===
|
||||||
|
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
|
||||||
|
// getters/setters viennent du Trait Shared. Le TimestampableBlamableSubscriber
|
||||||
|
// les remplit automatiquement au prePersist / preUpdate. Aucune redeclaration
|
||||||
|
// manuelle de ces proprietes ici.
|
||||||
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['category:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
// RG-1.02 + RG-1.03 : un name compose uniquement d'espaces doit declencher
|
||||||
|
// NotBlank. Le normalizer 'trim' fait le menage avant validation, alignant
|
||||||
|
// le comportement sur le trim cote Processor (qui s'applique apres) : ainsi
|
||||||
|
// POST {name: " "} -> 422 et POST {name: " Vis "} -> 201 avec "Vis"
|
||||||
|
// persiste, sans contradiction entre l'ordre Validate / Process.
|
||||||
|
#[ORM\Column(length: 120)]
|
||||||
|
#[Assert\NotBlank(message: 'Le nom est obligatoire.', normalizer: 'trim')]
|
||||||
|
#[Assert\Length(min: 2, max: 120, normalizer: 'trim')]
|
||||||
|
#[Groups(['category:read', 'category:write'])]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
// Code technique stable (slug MAJUSCULE du nom) — NOT NULL + unique parmi les
|
||||||
|
// actifs (index partiel `uq_category_code` possede par la migration). Genere
|
||||||
|
// par le CategoryProcessor a la creation puis fige (jamais recalcule sur
|
||||||
|
// renommage) : sert de cle metier deterministe (RG-1.03 / RG-1.29). Lecture
|
||||||
|
// seule cote API (hors groupe category:write) : le front filtre dessus mais
|
||||||
|
// ne le saisit pas.
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
#[Groups(['category:read'])]
|
||||||
|
private ?string $code = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||||
|
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
|
||||||
|
#[Groups(['category:read', 'category:write'])]
|
||||||
|
private ?CategoryType $categoryType = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
|
||||||
|
* Pas exposee en ecriture (groupe category:write exclu) : seul le DELETE,
|
||||||
|
* via le CategoryProcessor (ticket 0.3), pose la valeur.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||||
|
#[Groups(['category:read'])]
|
||||||
|
private ?DateTimeImmutable $deletedAt = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implemente CategoryInterface : code technique stable de la categorie.
|
||||||
|
*/
|
||||||
|
public function getCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCode(string $code): static
|
||||||
|
{
|
||||||
|
$this->code = $code;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategoryType(): ?CategoryType
|
||||||
|
{
|
||||||
|
return $this->categoryType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCategoryType(?CategoryType $categoryType): static
|
||||||
|
{
|
||||||
|
$this->categoryType = $categoryType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implemente CategoryInterface : code du type rattache (ou null). Permet
|
||||||
|
* aux modules tiers de filtrer/valider par type metier sans dependre de
|
||||||
|
* Catalog.
|
||||||
|
*/
|
||||||
|
public function getCategoryTypeCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->categoryType?->getCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeletedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||||
|
{
|
||||||
|
$this->deletedAt = $deletedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Domain\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryTypeRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type de categorie : referentiel statique classifiant les Category
|
||||||
|
* (ex: MATIERE, PRODUIT, SERVICE). Cree vide par la migration M0 ; son seed
|
||||||
|
* initial et son CRUD admin sont hors perimetre M0 (cf. spec-back § 9 HP-1).
|
||||||
|
*
|
||||||
|
* Lecture seule au M0 : seules les operations GetCollection et Get sont
|
||||||
|
* exposees, sous la meme permission que Category (catalog.categories.view).
|
||||||
|
*
|
||||||
|
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
|
||||||
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe `category:read`
|
||||||
|
* est ajoute sur chaque propriete pour que le type soit embarque dans la
|
||||||
|
* reponse d'une Category (cf. .claude/rules/backend.md § Serialization).
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('catalog.categories.view')",
|
||||||
|
normalizationContext: ['groups' => ['category_type:read']],
|
||||||
|
// Tri par defaut requis par la spec M0 § 4.6 : ordre alphabetique
|
||||||
|
// stable pour alimenter le <MalioSelect> du formulaire Category.
|
||||||
|
order: ['label' => 'ASC'],
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('catalog.categories.view')",
|
||||||
|
normalizationContext: ['groups' => ['category_type:read']],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: DoctrineCategoryTypeRepository::class)]
|
||||||
|
#[ORM\Table(name: 'category_type')]
|
||||||
|
// Contrainte d'unicite nommee pour matcher la migration (cf. Role/Permission).
|
||||||
|
#[ORM\UniqueConstraint(name: 'uq_category_type_code', columns: ['code'])]
|
||||||
|
class CategoryType
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['category_type:read', 'category:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 40)]
|
||||||
|
#[Groups(['category_type:read', 'category:read'])]
|
||||||
|
private ?string $code = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120)]
|
||||||
|
#[Groups(['category_type:read', 'category:read'])]
|
||||||
|
private ?string $label = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCode(string $code): static
|
||||||
|
{
|
||||||
|
$this->code = $code;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $label): static
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user