Compare commits

..

1 Commits

Author SHA1 Message Date
gitea-actions 206057cf83 chore: bump version to v0.1.142
Build & Push Docker Image / build (push) Successful in 21s
2026-06-18 12:51:10 +00:00
250 changed files with 2389 additions and 19845 deletions
-1
View File
@@ -12,7 +12,6 @@
"doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6", "doctrine/orm": "^3.6",
"dompdf/dompdf": "^3.0",
"lexik/jwt-authentication-bundle": "^3.2", "lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8", "nyholm/psr7": "^1.8",
Generated
+1 -446
View File
@@ -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": "224bae08ec63f217eabf5b2b611deaa0", "content-hash": "b029c1484227c926d39dfd3ae5cb0699",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -2520,161 +2520,6 @@
}, },
"time": "2026-02-08T16:21:46+00:00" "time": "2026-02-08T16:21:46+00:00"
}, },
{
"name": "dompdf/dompdf",
"version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
"time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{ {
"name": "lcobucci/jwt", "name": "lcobucci/jwt",
"version": "5.6.0", "version": "5.6.0",
@@ -3049,73 +2894,6 @@
}, },
"time": "2022-12-02T22:17:43+00:00" "time": "2022-12-02T22:17:43+00:00"
}, },
{
"name": "masterminds/html5",
"version": "2.10.1",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
},
"time": "2026-06-23T18:43:15+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -4159,86 +3937,6 @@
}, },
"time": "2021-10-29T13:26:27+00:00" "time": "2021-10-29T13:26:27+00:00"
}, },
{
"name": "sabberworm/php-css-parser",
"version": "v9.4.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.33 || 2.2.2",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.4.6",
"rector/type-perfect": "1.0.0 || 2.1.3",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.5.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
},
"time": "2026-06-18T15:10:53+00:00"
},
{ {
"name": "symfony/asset", "name": "symfony/asset",
"version": "v8.0.8", "version": "v8.0.8",
@@ -9081,149 +8779,6 @@
], ],
"time": "2026-03-30T15:14:47+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.24.0", "version": "v3.24.0",
+1 -6
View File
@@ -33,14 +33,9 @@ security:
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
jwt: ~ jwt: ~
# API JWT stateless : pas de `target` (redirection 302) — le logout
# renvoie 204 via ApiLogoutSuccessListener. Une redirection generait
# une URL absolue basee sur le Host (en dev : l'upstream proxy
# « nginx », non resolvable par le navigateur => ERR_NAME_NOT_RESOLVED
# + ~3 s de timeout DNS). Le cookie BEARER reste efface par
# delete_cookies.
logout: logout:
path: /api/logout path: /api/logout
target: /login
enable_csrf: false enable_csrf: false
delete_cookies: delete_cookies:
BEARER: BEARER:
-4
View File
@@ -40,7 +40,3 @@ services:
App\Module\Logistique\Application\Service\DsdAllocatorInterface: App\Module\Logistique\Application\Service\DsdAllocatorInterface:
alias: App\Module\Logistique\Infrastructure\Service\DsdAllocator alias: App\Module\Logistique\Infrastructure\Service\DsdAllocator
# M5 Logistique — Provider/Processor ticket de pesee (ERP-185)
App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface:
alias: App\Module\Logistique\Infrastructure\Service\WeighingTicketNumberAllocator
+26 -44
View File
@@ -38,27 +38,7 @@ declare(strict_types=1);
*/ */
return [ return [
// Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur // Section "Commerciale" : pole metier principal, remontee en tete de sidebar (ERP-71).
// site", distinct du repertoire Transport (M4, desormais rattache a la section
// Administration cote develop). Porte le ticket de pesee au pont bascule.
// Placee en tete de sidebar (avant Commerciale). L'item est gate par
// `logistique.weighing_tickets.view` ; la section disparait automatiquement
// (SidebarProvider) si le module `logistique` est desactive ou si l'user n'a
// pas la permission (Compta / Commerciale).
[
'label' => 'sidebar.logistique.section',
'icon' => 'mdi:truck-outline',
'items' => [
[
'label' => 'sidebar.logistique.weighing_tickets',
'to' => '/weighing-tickets',
'icon' => 'mdi:truck-outline',
'module' => 'logistique',
'permission' => 'logistique.weighing_tickets.view',
],
],
],
// Section "Commerciale" : pole metier principal (ERP-71).
// L'ordre interne des onglets et les permissions restent inchanges (simple deplacement // L'ordre interne des onglets et les permissions restent inchanges (simple deplacement
// du bloc, aucun gate touche). // du bloc, aucun gate touche).
[ [
@@ -98,6 +78,25 @@ return [
], ],
], ],
], ],
// Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur
// site", distinct du repertoire Transport (M4, desormais rattache a la section
// Administration cote develop). Porte le ticket de pesee au pont bascule.
// L'item est gate par `logistique.weighing_tickets.view` ; la section disparait
// automatiquement (SidebarProvider) si le module `logistique` est desactive ou
// si l'user n'a pas la permission (Compta / Commerciale).
[
'label' => 'sidebar.logistique.section',
'icon' => 'mdi:scale',
'items' => [
[
'label' => 'sidebar.logistique.weighing_tickets',
'to' => '/weighing-tickets',
'icon' => 'mdi:scale',
'module' => 'logistique',
'permission' => 'logistique.weighing_tickets.view',
],
],
],
// Section "Administration" : regroupe toutes les pages de configuration // Section "Administration" : regroupe toutes les pages de configuration
// applicative (RBAC, users, sites, audit log). // applicative (RBAC, users, sites, audit log).
// //
@@ -134,16 +133,6 @@ return [
'module' => 'transport', 'module' => 'transport',
'permission' => 'transport.carriers.view', 'permission' => 'transport.carriers.view',
], ],
// Catalogue produit (M6, ERP-197). Place juste sous le repertoire
// transporteurs (DECISION Matthieu 24/06). Admin-only : gate par
// `catalog.products.view` et son module owner `catalog`.
[
'label' => 'sidebar.catalog.products',
'to' => '/admin/products',
'icon' => 'mdi:package-variant-closed',
'module' => 'catalog',
'permission' => 'catalog.products.view',
],
[ [
'label' => 'sidebar.core.roles', 'label' => 'sidebar.core.roles',
'to' => '/admin/roles', 'to' => '/admin/roles',
@@ -172,16 +161,6 @@ return [
'module' => 'catalog', 'module' => 'catalog',
'permission' => 'catalog.categories.view', 'permission' => 'catalog.categories.view',
], ],
// Stockage (M7, ERP-210). Admin-only : gate par `catalog.storages.view`
// et son module owner `catalog`. Reutilise le referentiel StorageType
// du M6. Place pres des autres items Catalog (produits, categories).
[
'label' => 'sidebar.catalog.storages',
'to' => '/admin/storages',
'icon' => 'mdi:warehouse',
'module' => 'catalog',
'permission' => 'catalog.storages.view',
],
[ [
'label' => 'sidebar.core.audit_log', 'label' => 'sidebar.core.audit_log',
'to' => '/admin/audit-log', 'to' => '/admin/audit-log',
@@ -194,9 +173,6 @@ return [
// Section "Mon compte" : espace personnel. Accessible a tout user authentifie // Section "Mon compte" : espace personnel. Accessible a tout user authentifie
// (aucune permission RBAC requise, tous les items restent dans `core` pour // (aucune permission RBAC requise, tous les items restent dans `core` pour
// rester toujours presents meme quand les modules metier sont desactives). // rester toujours presents meme quand les modules metier sont desactives).
// La deconnexion a quitte cette section : elle vit desormais dans le footer
// de la sidebar (compte connecte + lien deconnexion + version, cf.
// frontend/app/layouts/default.vue + useLogout).
[ [
'label' => 'sidebar.account.section', 'label' => 'sidebar.account.section',
'icon' => 'mdi:account-circle-outline', 'icon' => 'mdi:account-circle-outline',
@@ -207,6 +183,12 @@ return [
'icon' => 'mdi:view-dashboard-outline', 'icon' => 'mdi:view-dashboard-outline',
'module' => 'core', 'module' => 'core',
], ],
[
'label' => 'sidebar.account.logout',
'to' => '/logout',
'icon' => 'mdi:logout',
'module' => 'core',
],
], ],
], ],
]; ];
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.156' app.version: '0.1.142'
+36 -99
View File
@@ -172,16 +172,14 @@ Pattern Starseed standard (miroir M1→M4) :
- `WeighingTicket implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard). - `WeighingTicket implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard).
- **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.logistique_weighingticket` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`). - **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.logistique_weighingticket` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`).
### 2.12 Bon de pesée — PDF généré côté serveur via template Twig (RG-5.08) ### 2.12 Impression du ticket / bon de pesée (RG-5.08)
> **DÉCISION Matthieu (17/06)** : le **bon de pesée est généré côté back** par un **template Twig → PDF** (et non un gabarit imprimé par le navigateur). **OWNER : Tristan** (ticket back dédié, cf. § 10). Cette spec en pose le contrat (endpoint, contenu, données). > **OWNER : Tristan.** La **réalisation du bon d'impression** (gabarit du ticket de pesée, mise en page, déclenchement de l'impression) est **prise en charge par Tristan lui-même** — hors de la découpe back/front standard du M5. Cette spec en pose **le contrat attendu** (déclencheur, contenu, données disponibles) pour qu'il puisse s'y brancher sans rétro-spec.
Contrat attendu : Contrat attendu :
- **Endpoint** : `GET /api/weighing_tickets/{id}/print.pdf` (opération API Platform dédiée, **pas de controller** — provider renvoyant un binaire). Sécurité `is_granted('logistique.weighing_tickets.view')`. Réponse `Content-Type: application/pdf` (inline). - **Déclencheur** : à la **validation** (création), l'API renvoie le ticket complet ; le front ouvre une **modal d'impression**. En **modification**, un bouton **« Imprimer »** est disponible (absent à l'ajout — docx / RG-5.08).
- **Rendu** : un template **Twig** (`templates/logistique/weighing_ticket_print.html.twig`) hydraté avec le ticket → converti en PDF via le générateur PDF du projet (ex. Dompdf / wkhtmltopdf / Gotenberg — s'aligner sur l'existant ; sinon proposer une lib et la cadrer avec Matthieu). - **Contenu minimal du bon** : numéro (`{siteCode}-TP-{NNNN}`), site, contrepartie (Client / Fournisseur / Autre + libellé), immatriculation, **pesée à vide** (date/poids/DSD), **pesée à plein** (date/poids/DSD), **poids net** (= plein vide), date d'édition.
- **Contenu du bon** : numéro (`{siteCode}-TP-{NNNN}`), site, contrepartie (Client / Fournisseur / Autre + libellé), immatriculation, **pesée à vide** (date/poids/DSD), **pesée à plein** (date/poids/DSD), **poids net** (= plein vide), date d'édition. (En-tête / logo / mentions = à caler par Tristan.) - **Données** : toutes disponibles dans la réponse `GET /api/weighing_tickets/{id}` (§ 4.0) — aucun champ supplémentaire requis côté API. Si Tristan opte pour un **PDF serveur**, prévoir l'endpoint `GET /api/weighing_tickets/{id}/print.pdf` (HP-M5-04) ; sinon impression navigateur d'un gabarit front.
- **Données** : toutes déjà disponibles sur le ticket (mêmes champs que `GET /api/weighing_tickets/{id}` § 4.0) — aucun champ API supplémentaire requis.
- **Déclencheurs front** (RG-5.08) : à la **validation** (création), le front ouvre l'aperçu/PDF servi par cet endpoint ; en **modification**, le bouton **« Imprimer »** ouvre le même PDF (absent à l'ajout).
### 2.13 Pas d'archive ; soft delete préparé non exposé ### 2.13 Pas d'archive ; soft delete préparé non exposé
@@ -506,19 +504,17 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
**DÉTAIL — maillons** : scalaires + `emptyDate/emptyWeight/emptyDsd/...` + `full*` ∈ `weighing_ticket:item:read` ; `client`/`supplier`/`site` embarqués (`client:read`/`supplier:read`/`site:read`). **DÉTAIL — maillons** : scalaires + `emptyDate/emptyWeight/emptyDsd/...` + `full*` ∈ `weighing_ticket:item:read` ; `client`/`supplier`/`site` embarqués (`client:read`/`supplier:read`/`site:read`).
### 4.0.bis Réponse JSON de référence (DoD — CAPTURÉE sur l'API réelle) ### 4.0.bis Réponse JSON de référence (DoD — à CAPTURER sur l'API réelle)
> **Definition of Done** (miroir M2/M3/M4) : ✅ **FAIT (ERP-187)**. Le JSON ci-dessous est la réponse **RÉELLE** capturée par le test `WeighingTicketSerializationContractTest::testListAndDetailSerializationContract` (ticket créé via `POST /api/weighing_tickets` — numérotation serveur réelle — contrepartie Client, pesée vide + plein AUTO). Re-capturable : `WEIGHING_TICKET_DOD_DUMP=1` → `/tmp/weighing-ticket-dod-{list,detail}.json`. **Feu vert front.** Toute donnée affichée par le front DOIT apparaître dans ce JSON. > **Definition of Done** (miroir M2/M3/M4) : avant les écrans front, **capturer la réponse RÉELLE** via un test PHPUnit (`WeighingTicketSerializationContractTest`, ticket complet seedé : contrepartie Client, pesée vide + plein) et la coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON.
> >
> **Pièges re-testés — tous VERTS** (assertions dans le test) : > **Pièges à re-tester** :
> 1. `client` sort en **objet embarqué** (`client:read`), pas en IRI nu ; `supplier` **omis car null** (`skip_null_values` — jamais un IRI nu). Sur une contrepartie Fournisseur, `supplier` sortirait symétriquement en objet (`supplier:read`). > 1. `client` / `supplier` doivent sortir en **objet embarqué**, pas en IRI nu → read-groups `client:read`/`supplier:read`.
> 2. Booléen `plateFreeFormat` : **clé présente** (getter `isPlateFreeFormat()` + `SerializedName('plateFreeFormat')`). > 2. Booléen `plateFreeFormat` : clé présente (piège #3 M1 → getter + `SerializedName` si besoin).
> 3. `number` présent et formaté `{siteCode}-TP-{NNNN}` (ici `86-TP-0001`). > 3. `number` présent et formaté `{siteCode}-TP-{NNNN}`.
> 4. `netWeight` cohérent = `full - empty` = `14300 - 7150` = **`7150`** (RG-5.05). > 4. `netWeight` cohérent = `full - empty` (plein vide, RG-5.05).
>
> **Note `skip_null_values`** : les champs null sont **omis** du JSON (ex. `supplier`, `otherLabel`, `emptyManualNumber`, `fullManualNumber` absents quand null). Le front ne doit pas présumer leur présence — lire avec un défaut (`?? null`).
**`GET /api/weighing_tickets?search=86-TP-0001` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`), filtrée site courant (§ 2.3). Capture réelle : **`GET /api/weighing_tickets` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`), filtrée site courant (§ 2.3) :
```jsonc ```jsonc
{ {
@@ -528,94 +524,41 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
"totalItems": 1, "totalItems": 1,
"member": [ "member": [
{ {
"@id": "/api/weighing_tickets/9", "@id": "/api/weighing_tickets/1",
"@type": "WeighingTicket", "@type": "WeighingTicket",
"id": 9, "id": 1,
"number": "86-TP-0001", "number": "86-TP-0001",
"counterpartyType": "CLIENT", "counterpartyType": "CLIENT",
"client": { "client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" },
"@id": "/api/clients/629", "supplier": null,
"@type": "Client", "otherLabel": null,
"id": 629, "displayDate": "2026-06-17T09:12:00+02:00",
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE", "netWeight": 12340,
"triageService": false,
"categories": [],
"createdAt": "2026-06-18T11:50:47+02:00",
"updatedAt": "2026-06-18T11:50:47+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me",
"sites": [],
"isArchived": false
},
"plateFreeFormat": false, "plateFreeFormat": false,
"netWeight": 7150, "createdAt": "2026-06-17T09:12:00+02:00",
"createdAt": "2026-06-18T11:50:48+02:00", "updatedAt": "2026-06-17T09:12:00+02:00"
"updatedAt": "2026-06-18T11:50:48+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me",
"displayDate": "2026-06-17T09:12:00+02:00"
// supplier / otherLabel omis (null → skip_null_values)
} }
], ],
"view": { "@id": "/api/weighing_tickets?search=86-TP-0001", "@type": "PartialCollectionView" } "view": { "@id": "/api/weighing_tickets", "@type": "PartialCollectionView" }
} }
``` ```
**`GET /api/weighing_tickets/9` (DÉTAIL)** — ajoute le site embarqué (avec `code`), l'immatriculation et les deux pesées. Capture réelle : **`GET /api/weighing_tickets/{id}` (DÉTAIL)** — ajoute les pesées :
```jsonc ```jsonc
{ {
"@context": "/api/contexts/WeighingTicket", "@id": "/api/weighing_tickets/1",
"@id": "/api/weighing_tickets/9",
"@type": "WeighingTicket", "@type": "WeighingTicket",
"id": 9,
"number": "86-TP-0001",
"site": {
"@id": "/api/sites/1",
"@type": "Site",
"id": 1, "id": 1,
"name": "Chatellerault", "number": "86-TP-0001",
"code": "86", "site": { "@id": "/api/sites/1", "@type": "Site", "id": 1, "name": "Châtellerault", "code": "86" },
"street": "14 All. d'Argenson",
"postalCode": "86100",
"city": "Châtellerault",
"color": "#056CF2",
"createdAt": "2026-06-17T17:07:47+02:00",
"updatedAt": "2026-06-17T17:07:47+02:00",
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
},
"counterpartyType": "CLIENT", "counterpartyType": "CLIENT",
"client": { "client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" },
"@id": "/api/clients/629",
"@type": "Client",
"id": 629,
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
"triageService": false,
"categories": [],
"createdAt": "2026-06-18T11:50:47+02:00",
"updatedAt": "2026-06-18T11:50:47+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me",
"sites": [],
"isArchived": false
},
"immatriculation": "AB-123-CD", "immatriculation": "AB-123-CD",
"plateFreeFormat": false, "plateFreeFormat": false,
"emptyDate": "2026-06-17T09:00:00+02:00", "emptyDate": "2026-06-17T09:00:00+02:00", "emptyWeight": 14660, "emptyDsd": 41, "emptyMode": "AUTO", "emptyManualNumber": null,
"emptyWeight": 7150, "fullDate": "2026-06-17T09:12:00+02:00", "fullWeight": 27000, "fullDsd": 42, "fullMode": "AUTO", "fullManualNumber": null,
"emptyDsd": 1, "netWeight": 12340
"emptyMode": "AUTO",
"fullDate": "2026-06-17T09:12:00+02:00",
"fullWeight": 14300,
"fullDsd": 2,
"fullMode": "AUTO",
"netWeight": 7150,
"createdAt": "2026-06-18T11:50:48+02:00",
"updatedAt": "2026-06-18T11:50:48+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me",
"displayDate": "2026-06-17T09:12:00+02:00"
// emptyManualNumber / fullManualNumber omis (null → skip_null_values)
} }
``` ```
@@ -666,12 +609,6 @@ Action **autonome** (le ticket n'est pas encore créé quand on déclenche la pe
- Colonnes : Numéro, Contrepartie (Client/Fournisseur/Autre + nom), Date, Immatriculation, Poids vide, Poids plein, **Poids net**, DSD vide/plein. - Colonnes : Numéro, Contrepartie (Client/Fournisseur/Autre + nom), Date, Immatriculation, Poids vide, Poids plein, **Poids net**, DSD vide/plein.
- Génération via le helper XLSX standard projet (skill `xlsx`). Endpoint : provider dédié renvoyant un binaire (`Content-Type` xlsx) — whitelisté pagination (`EXCLUDED`) car export complet. - Génération via le helper XLSX standard projet (skill `xlsx`). Endpoint : provider dédié renvoyant un binaire (`Content-Type` xlsx) — whitelisté pagination (`EXCLUDED`) car export complet.
### 4.6 Impression — `GET /api/weighing_tickets/{id}/print.pdf` (bon de pesée, OWNER Tristan)
- Opération API Platform dédiée (provider renvoyant un binaire PDF, **pas de controller**). Sécurité `is_granted('logistique.weighing_tickets.view')`.
- Rendu d'un **template Twig** (`templates/logistique/weighing_ticket_print.html.twig`) → PDF (cf. § 2.12). `Content-Type: application/pdf`, inline.
- Contenu : cf. § 2.12. Données déjà portées par le ticket — aucun champ API supplémentaire.
## 5. RBAC, module & sidebar ## 5. RBAC, module & sidebar
### 5.1 `LogistiqueModule::permissions()` ### 5.1 `LogistiqueModule::permissions()`
@@ -744,7 +681,7 @@ final class WeighingTicketFieldNormalizer
| **RG-5.05** | back | Poids net = `poids plein poids vide`, calculé serveur, exposé en liste/détail (§ 2.8 — confirmé Matthieu 17/06). | | **RG-5.05** | back | Poids net = `poids plein poids vide`, calculé serveur, exposé en liste/détail (§ 2.8 — confirmé Matthieu 17/06). |
| **RG-5.06** | docx+back | Pesée bascule indisponible → erreur explicite + bascule en pesée manuelle. Au M5, le pont est un **stub** (poids aléatoire ∈ [10000,50000] kg, § 2.6). | | **RG-5.06** | docx+back | Pesée bascule indisponible → erreur explicite + bascule en pesée manuelle. Au M5, le pont est un **stub** (poids aléatoire ∈ [10000,50000] kg, § 2.6). |
| **RG-5.07** | docx | Formulaire à vide : `Date` = date du jour par défaut ; `Poids` et `DSD` **readonly** (remplis par la pesée, pas saisis). | | **RG-5.07** | docx | Formulaire à vide : `Date` = date du jour par défaut ; `Poids` et `DSD` **readonly** (remplis par la pesée, pas saisis). |
| **RG-5.08** | docx | « Valider » (création) → enregistre + ouvre le **bon de pesée (PDF servi par le back)**. En modification : bouton « Valider » → « Enregistrer », bouton « Imprimer » disponible (absent à l'ajout) → ouvre le même PDF. Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. **Bon de pesée = PDF généré back via template Twig, OWNER Tristan** (§ 2.12 / § 4.6). | | **RG-5.08** | docx | « Valider » (création) → enregistre + ouvre la modal d'impression. En modification : bouton « Valider » → « Enregistrer », bouton d'impression disponible (absent à l'ajout). Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. **Le bon d'impression est réalisé par Tristan** (§ 2.12). |
| **RG-5.09** | back | Site & numéro immuables après création ; liste cloisonnée par site courant (§ 2.3, à confirmer). | | **RG-5.09** | back | Site & numéro immuables après création ; liste cloisonnée par site courant (§ 2.3, à confirmer). |
| **RG-5.10** | back | Normalisation immatriculation (trim/UPPER/format) côté serveur (§ 6). | | **RG-5.10** | back | Normalisation immatriculation (trim/UPPER/format) côté serveur (§ 6). |
@@ -769,7 +706,7 @@ Cohérence inter-champs (RG-5.03, RG-5.01) implémentée via `#[Assert\Callback]
| HP-M5-01 | Vue multi-sites des tickets (retirer le cloisonnement + filtre `?siteId=`) si demandé (§ 2.3). | | HP-M5-01 | Vue multi-sites des tickets (retirer le cloisonnement + filtre `?siteId=`) si demandé (§ 2.3). |
| HP-M5-02 | Driver matériel réel du pont bascule (protocole série/TCP, parsing trame, reconnexion) derrière `WeighbridgeReaderInterface` (§ 2.6). | | HP-M5-02 | Driver matériel réel du pont bascule (protocole série/TCP, parsing trame, reconnexion) derrière `WeighbridgeReaderInterface` (§ 2.6). |
| HP-M5-03 | Sens réception-expédition explicite + contrôle de signe du net (le net reste `plein vide`, § 2.8). | | HP-M5-03 | Sens réception-expédition explicite + contrôle de signe du net (le net reste `plein vide`, § 2.8). |
| ~~HP-M5-04~~ | **Passé en périmètre** : bon de pesée = PDF serveur via template Twig → ticket back dédié (OWNER Tristan, § 2.12 / § 4.6). | | HP-M5-04 | Génération PDF serveur du ticket (`/print.pdf`) si l'impression navigateur ne suffit pas (§ 2.12). |
| HP-M5-05 | Archivage fonctionnel des tickets (non prévu au docx — § 2.13). | | HP-M5-05 | Archivage fonctionnel des tickets (non prévu au docx — § 2.13). |
## 10. Tickets Lesstime (à découper — back en tête) ## 10. Tickets Lesstime (à découper — back en tête)
@@ -783,8 +720,8 @@ Cohérence inter-champs (RG-5.03, RG-5.01) implémentée via `#[Assert\Callback]
| 4 | `WeighingTicketProvider` + `WeighingTicketProcessor` (numérotation, RG-5.03/5.05, normalisation) | Backend | | 4 | `WeighingTicketProvider` + `WeighingTicketProcessor` (numérotation, RG-5.03/5.05, normalisation) | Backend |
| 5 | Export XLSX | Backend | | 5 | Export XLSX | Backend |
| 6 | Tests PHPUnit RG-5.01→5.10 + capture contrat JSON | Backend | | 6 | Tests PHPUnit RG-5.01→5.10 + capture contrat JSON | Backend |
| 6.bis (ERP-192) | **Bon de pesée — PDF via template Twig** (`/print.pdf`, § 2.12 / § 4.6) | **Backend (OWNER Tristan)** |
| 7 | Page liste `/weighing-tickets` (usePaginatedList) + export | Frontend | | 7 | Page liste `/weighing-tickets` (usePaginatedList) + export | Frontend |
| 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) + ouverture PDF à la validation | Frontend | | 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) | Frontend |
| 9 | Écran Modification + bouton « Imprimer » (ouvre le PDF back) | Frontend | | 9 | Écran Modification (la **modal/bon d'impression** = **Tristan**, § 2.12) | Frontend |
| 10 | i18n + libellé audit + branchement site courant | Frontend | | 10 | i18n + libellé audit + branchement site courant | Frontend |
| — | **Bon d'impression du ticket de pesée** | **Tristan (hors découpe M5)** |
-698
View File
@@ -1,698 +0,0 @@
---
# === IDENTITÉ ===
module: M6
nom: "Catalogue produit"
ecran: produits
owner_spec: Matthieu
backup_spec: Tristan
version: V0.1
date_redaction: 2026-06-24
# Historique :
# V0.1 (2026-06-24) — Spec back initiale. Restitution + précisions back du docx fonctionnel
# « M6-produit-V0 » (V0, 15/06/2026, validation client en attente).
# Décisions Matthieu (24/06) :
# (1) Produit logé dans le module EXISTANT `Catalog` (pas de nouveau module) ;
# item sidebar dans la section « Administration », sous « Répertoire transporteurs ».
# (2) « Type de stockage » : référentiel minimal `StorageType` créé maintenant (provisoire,
# en attendant la liste définitive d'Aurore), seedé avec la liste Figma (node 1503-34285).
# (3) « Code produit » = « Numéro » de la liste : MÊME champ, saisi, UNIQUE global (409 doublon).
# (4) « État du produit » : Achat / Vendu / Autre, multi-select, AU MOINS 1 requis
# (corrige l'incohérence « Autre » vs « Aucun » du docx).
# (5) PÉRIMÈTRE V0 = CRUD produit classique uniquement. Les onglets « Fournisseurs » et
# « Clients » sont des PLACEHOLDERS « en cours de développement » (dépendent d'un module
# Contrat inexistant) — hors périmètre, tracés HP-M6-01.
# === LIENS ===
spec_front: ./spec-front.md
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1503-34285&p=f&m=dev"
trace_fonctionnelle: "uploads/M6-produit-V0.pdf (V0, 15/06/2026, validation client en attente)"
# === LIEN LESSTIME ===
lesstime_project_id: 6
lesstime_taskgroup_id: 36 # M6 — Catalogue produit (ERP-197 → ERP-207)
statut_global: pret_a_dev
# === DÉPENDANCES AMONT ===
depend_de:
- Catalog # Module hôte (REQUIRED). Category + CategoryType (ajout du type PRODUIT) + nouveau StorageType + Product
- Sites # Site (relation ManyToMany product↔site, RG-6.04)
- Core # User, Role, Permission, Audit, JWT
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + CategoryInterface
---
# Spec back — Module 6 : Catalogue produit
## 1. Contexte
Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx `M6-produit-V0`, V0 du 15/06/2026, **validation client en attente**) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion (RG-6.01 → RG-6.10), tests, hors-périmètre.
**Module cible** : module **EXISTANT `Catalog`** (`src/Module/Catalog/`) — DÉCISION Matthieu (24/06). Le docx parle d'un « Module 7 — Catalogue produit » rattaché à « l'Administration », mais le projet possède déjà un module `Catalog` (`ID = 'catalog'`, `REQUIRED = true`) qui porte `Category` / `CategoryType`. « Catalogue produit » y a sa place naturelle : on **n'ajoute pas de module**, on ajoute l'entité `Product` (+ le référentiel `StorageType`) au module `Catalog`. L'item de menu vit dans la section **Administration** de la sidebar, **sous « Répertoire transporteurs »** (cf. § 5.3).
> **RETEX obligatoire (M1→M5)** : ~80 % des frictions venaient du **contrat de sérialisation** (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M6. On réutilise le pattern Provider/Processor + normalisation serveur + Timestampable/Blamable + audit i18n + soft delete posé aux modules précédents, et la taxonomie `Category` codée (ERP-78).
**Dépendances déjà en place sur `develop`** :
- `Catalog``Category` (taxonomie codée, soft delete, `CategoryInterface`) + `CategoryType` (référentiel statique, types CLIENT / FOURNISSEUR / PRESTATAIRE seedés). Le type **`PRODUIT` n'est PAS encore seedé** — le M6 l'ajoute (§ 2.5).
- `Sites` → 3 sites Châtellerault (`code` 86) / Saint-Jean (17) / Pommevic (82) ; `Site.code` déjà mappé ; `SiteInterface`.
- `Shared``TimestampableBlamableTrait` + `Subscriber` (ERP-52).
- `Core` → User, Role, Permission, Audit, JWT.
## 1.bis Remise en question du docx (incohérences relevées + résolutions)
> Le docx V0 est volontairement léger. Voici les points ambigus ou contradictoires relevés à la relecture, et la décision retenue (validée Matthieu 24/06). **Toute la spec qui suit applique ces décisions.**
| # | Point du docx | Problème | Décision retenue |
|---|---|---|---|
| C1 | « Module 7 — Catalogue produit » / « Module Administration » | Le projet n'a pas de « Module 7 » ni de module « Administration » ; un module `Catalog` existe déjà. | **Produit logé dans `Catalog`** ; item sidebar dans la **section Administration**, sous « Répertoire transporteurs » (§ 2.1 / § 5.3). |
| C2 | Colonnes liste = `Nom`, `Numéro`, `Catégorie` ; formulaire = `Nom`, `Code produit`, `Catégorie` | « Numéro » (liste) vs « Code produit » (formulaire) : 2 noms pour quoi ? | **Même champ** : `code` (= « Numéro » = « Code produit »), **saisi**, **unique global**, 409 sur doublon (RG-6.01). La colonne liste « Numéro » affiche `code`. |
| C3 | « État du produit » : Multi-select **obligatoire**, valeurs *Achat / Vendu / Autre* | Les onglets parlent de *Achat / Vendu / **Aucun*** → « Autre » ≠ « Aucun ». Et « obligatoire » + « Aucun » se contredisent. | **Enum `PURCHASE` / `SALE` / `OTHER`**, multi-select, **≥ 1 obligatoire** (RG-6.02). « Aucun » des onglets = « ni Achat ni Vendu » (donc `OTHER` seul). |
| C4 | « Type de stockage » : « *liste fournie par Aurore* en fonction des sites » | Aucun référentiel de stockage n'existe en base ; la vraie liste est en attente. | **Référentiel minimal `StorageType` créé maintenant** (provisoire, **plat**), seedé avec la liste Figma (node 1503-34285) ; multi-select listant **tous** les types (plus de filtrage par site — décision 26/06, § 2.4). La disponibilité par site relèvera du futur module **Stockage**. À re-seeder quand Aurore livre la liste définitive (HP-M6-02). |
| C5 | « Catégorie produit » : « Liste des catégories produit » | Le type `PRODUIT` n'est pas seedé ; aucune catégorie produit n'existe. | Le M6 **seede le `CategoryType` PRODUIT** + quelques `Category` produit, et le select est **filtré `?typeCode=PRODUIT`** (RG-6.05, § 2.5). |
| C6 | « Fabriqué » / « Contient de la mélasse » : « apparaît si État = Vendu » | Comportement front only ? Que stocke-t-on si l'état n'est plus Vendu ? | Booléens **conditionnés à `SALE`** : saisis seulement si l'état contient `SALE`, sinon **forcés `false` serveur** (RG-6.03). |
| C7 | RBAC : Admin = Tout, tous les autres rôles = Non | Très restrictif (admin-only). Confirmé ? | **Confirmé** : `catalog.products.view` / `.manage` attribués **au seul rôle Admin** (§ 5.2). |
| C8 | Onglets « Fournisseurs » / « Clients » (contrats, prestation de triage, contrats TAF) | Référencent une notion de **Contrat** (client/fournisseur) **inexistante** dans le code. | **Hors périmètre V0** : onglets rendus en **placeholder « en cours de développement »** (comme les autres onglets non encore dev). Tracé HP-M6-01 (§ 9). |
## 2. Décisions d'archi
### 2.1 Entité `Product` dans le module `Catalog`
Ajout au module **`Catalog`** (pas de nouveau module — C1) :
- Entité racine **`Product`** sous `src/Module/Catalog/Domain/Entity/Product.php`.
- Référentiel **`StorageType`** sous `src/Module/Catalog/Domain/Entity/StorageType.php` (§ 2.4).
- Permissions `catalog.products.view` / `catalog.products.manage` ajoutées à `CatalogModule::permissions()` (§ 5.1).
- Pas de nouveau layer front (le module `catalog` n'a pas de layer dédié — les écrans admin du Catalog vivent dans le shell `frontend/app/` / `frontend/shared/`, comme `/admin/categories`). Route Nuxt : `/admin/products` (cf. spec-front).
**Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique)** — comme M2→M5 : `Product` référence `Site` (Sites) via une **relation ORM** (ManyToMany). Donnée de référence partagée, aucun service/repository d'un autre module appelé. `Category` et `StorageType` appartiennent au **même** module `Catalog` → relations internes classiques.
### 2.2 IDs — convention `INT` (alignée Catalog / Core)
`Product` et `StorageType` s'alignent sur la convention du module `Catalog` : **`INT GENERATED BY DEFAULT AS IDENTITY`**. Horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`).
### 2.3 État du produit — multi-valeur `states` (C3 / RG-6.02)
`états` est un **multi-select** : un produit peut être à la fois `PURCHASE` et `SALE`. Modélisation : colonne **`states JSONB NOT NULL DEFAULT '[]'`** (tableau de chaînes), valeurs autorisées `PURCHASE` / `SALE` / `OTHER`, **≥ 1** (Callback + CHECK de non-vacuité).
> **Alternative écartée** : 3 colonnes booléennes (`is_purchase`/`is_sale`/`is_other`). Plus simple à requêter mais s'éloigne de la sémantique « multi-select » et multiplie les colonnes. Le JSONB est retenu pour la fidélité au champ unique du docx ; si un besoin de filtrage SQL fin apparaît (HP), on bascule sur une table de jonction `product_state`.
Pilotage des champs conditionnels (RG-6.03) : `manufactured` et `containsMolasses` ne sont **saisissables que si `states` contient `SALE`** ; sinon forcés `false` côté serveur (Processor) — pas de state machine.
### 2.4 Référentiel `StorageType` (C4 / RG-6.06) — PROVISOIRE, référentiel PLAT
> **Décision Matthieu (24/06)** : créer un **référentiel minimal** en attendant la liste/mapping définitifs d'Aurore. Seed = liste Figma (node 1503-34285).
>
> **Décision Tristan (26/06)** : `StorageType` devient un **référentiel PLAT** — plus de rattachement aux sites. La disponibilité « tel type sur tel site » relèvera de la **future entité `Stockage`** (module Stockage : un stockage = 1 site + 1 type), dérivée des stockages réels. On **retire** donc la jointure `storage_type_site` et **tout filtrage du multi-select par site** (migration `Version20260626100000` : drop de la jointure + seed idempotent). Le référentiel est aussi seedé **en migration** (prod-safe, comme `payment_type`/`bank`/`country`), la fixture ne servant qu'au re-seed dev après purge.
- Entité `StorageType` (`Catalog`) : `id`, `code` (slug MAJUSCULE stable, unique), `label` (FR affiché). **Plus de relation `sites`.**
- **Seed (10 valeurs, Figma)** : Boisseau, Boisseau dosage, Case, Cellule, Container, Cuve mélasse, Stockage big bag, Stockage palette, Tas, Zone. Seedées en migration (`ON CONFLICT (code) DO NOTHING`) **et** par `StorageTypeFixtures` (dev/test).
- Le champ produit « Type de stockage » est un **multi-select listant TOUS les types** : `GET /api/storage_types` (plus de paramètre `?siteId[]=`).
- **Provisoire** : codes et libellés sont à revalider/re-seeder à réception de la liste Aurore (HP-M6-02). Référentiel en **lecture seule** au M6 (pas de CRUD admin du StorageType — HP-M6-03).
### 2.5 Catégorie produit — type `PRODUIT` (C5 / RG-6.05)
- Le M6 **seede le `CategoryType` `PRODUIT`** (code `PRODUIT`, label « Produit ») : ajout dans **`CategoryTypeFixtures::TYPES`** ET dans une **migration de seed** (miroir dev/prod, comme CLIENT/FOURNISSEUR/PRESTATAIRE — cf. `CategoryTypeFixtures` docblock).
- Le M6 seede aussi quelques **`Category` de type PRODUIT** (ex. provisoires : « Céréales », « Oléagineux », « Aliments du bétail », « Engrais ») pour alimenter le select. Codes auto-générés par `CategoryCodeGenerator` (slug MAJUSCULE stable).
- `Product.category` = **ManyToOne `Category`** (obligatoire). Le select du formulaire est **filtré `?typeCode=PRODUIT`** (provider Category existant — filtre `typeCode` déjà supporté). Lecture du référentiel via `catalog.categories.read_ref` ou `.view` (déjà en place).
> **Garde-fou** : on **ne contraint pas** en base que `category` soit de type PRODUIT (le filtrage est applicatif via le select + une validation `#[Assert\Callback]` côté Processor qui rejette une catégorie non-PRODUIT en 422). Justification : éviter un couplage SQL fragile au référentiel type.
### 2.6 Audit & traces temporelles
Pattern Starseed standard (miroir M1→M5) :
- `#[Auditable]` sur `Product`. Pas de champ sensible (password/token) → pas d'`#[AuditIgnore]`.
- Audit des relations (`category`, `sites`, `storageTypes`) tracé automatiquement (ManyToMany inclus).
- `Product implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard).
- **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.catalog_product` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)` = `catalog_product`).
- `StorageType` = référentiel **statique** en lecture seule → **pas** de Timestampable/Blamable, **pas** `#[Auditable]` (whitelister dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`, miroir `CategoryType`).
### 2.7 Soft delete préparé ; pas de Delete exposé au M6
Le docx M6 ne prévoit **ni archivage ni suppression** (actions = + Ajouter / Exporter / Filtrer). On **n'expose pas** de `Delete`. On prépare néanmoins une colonne `deleted_at` (soft delete technique) **non exposée** (cohérent avec `Category` et le pattern M5). Le provider exclut par défaut les produits soft-deleted.
## 3. Modèle de données
### 3.1 Diagramme
```
+------------------+ +------------------------+
| site (Sites) | | category_type (Catalog)| + seed type PRODUIT (§ 2.5)
+------------------+ +------------------------+
^ ^ ^
| | | (ManyToMany existant)
product_ | | storage_type_ |
site | | site +------------------+
| | | category | (type PRODUIT)
+------------------+ +------------------+ +------------------+
| product | | storage_type | ^
| id (PK) | | id (PK) | | category_id (FK, NOT NULL)
| code (UNIQUE) | | code (UNIQUE) |----------+
| name | | label | (product.category ManyToOne)
| states (JSONB) | +------------------+
| manufactured | ^
| contains_molasses| | product_storage_type (ManyToMany)
| category_id (FK) |--------+
| deleted_at |
| created_at/by … |
+------------------+
^ ^
| | product_site (ManyToMany) / product_storage_type (ManyToMany)
+---+
```
Tables de jonction : `product_site (product_id, site_id)`, `product_storage_type (product_id, storage_type_id)`. *(La jonction `storage_type_site` initialement créée par ERP-198 a été **supprimée** : `StorageType` est devenu un référentiel plat — migration `Version20260626100000`, décision 26/06, § 2.4.)*
### 3.2 Migration Doctrine — SQL Postgres (illustratif)
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (postérieur aux migrations existantes).
> **Même justification qu'aux M1→M5** : FK cross-module (`user`, `site`, `category`) → le namespace modulaire casserait l'ordre sur `make db-reset` (exception racine de la règle ABSOLUE n°11).
>
> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par `addStandardTimestampableBlamableComments`.
```sql
-- =====================================================================
-- Référentiel des types de stockage (provisoire — § 2.4 / RG-6.06)
-- =====================================================================
CREATE TABLE storage_type (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
code VARCHAR(40) NOT NULL,
label VARCHAR(120) NOT NULL
);
CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code);
-- NB : storage_type_site (créée ici par ERP-198) est DROPPÉE par la migration
-- Version20260626100000 — StorageType est un référentiel plat (décision 26/06, § 2.4).
CREATE TABLE storage_type_site (
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE CASCADE,
site_id INT NOT NULL REFERENCES site(id) ON DELETE CASCADE,
PRIMARY KEY (storage_type_id, site_id)
);
-- =====================================================================
-- Table principale `product`
-- =====================================================================
CREATE TABLE product (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
code VARCHAR(50) NOT NULL, -- = « Numéro » liste, unique global (RG-6.01)
name VARCHAR(255) NOT NULL,
states JSONB NOT NULL DEFAULT '[]'::jsonb, -- PURCHASE|SALE|OTHER, >=1 (RG-6.02)
manufactured BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
contains_molasses BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, -- type PRODUIT (RG-6.05)
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE, -- soft delete, non exposé (§ 2.7)
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
CONSTRAINT chk_product_states_not_empty CHECK (jsonb_array_length(states) >= 1)
);
-- Unicité GLOBALE du code parmi les actifs (soft delete toléré) — index partiel.
CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL;
CREATE INDEX idx_product_category ON product (category_id);
CREATE INDEX idx_product_deleted_at ON product (deleted_at);
CREATE INDEX idx_product_created_by ON product (created_by);
CREATE INDEX idx_product_updated_by ON product (updated_by);
-- =====================================================================
-- Jonctions produit ↔ sites / types de stockage
-- =====================================================================
CREATE TABLE product_site (
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
PRIMARY KEY (product_id, site_id)
);
CREATE TABLE product_storage_type (
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE RESTRICT,
PRIMARY KEY (product_id, storage_type_id)
);
-- =====================================================================
-- Seed du type de catégorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
-- =====================================================================
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
ON CONFLICT (code) DO NOTHING;
```
### 3.2.bis Commentaires SQL obligatoires (échantillon)
```php
$this->addSql("COMMENT ON TABLE product IS 'Produits du catalogue (M6 Catalog) — état Achat/Vendu/Autre, sites de disponibilité, catégorie produit, types de stockage.'");
$this->addSql("COMMENT ON COLUMN product.code IS 'Code produit (= « Numéro » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active.'");
$this->addSql("COMMENT ON COLUMN product.states IS 'États du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02). Pilote les champs conditionnels.'");
$this->addSql("COMMENT ON COLUMN product.manufactured IS '« Fabriqué » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
$this->addSql("COMMENT ON COLUMN product.contains_molasses IS '« Contient de la mélasse » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
$this->addSql("COMMENT ON COLUMN product.category_id IS 'Catégorie produit (FK category, type PRODUIT) — obligatoire, validée applicativement (RG-6.05).'");
$this->addSql("COMMENT ON COLUMN product.deleted_at IS 'Horodatage de suppression logique (soft delete) — non exposé au M6 ; la liste exclut les produits supprimés (§ 2.7).'");
$this->addSql("COMMENT ON TABLE storage_type IS 'Référentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Silo, Tas… (RG-6.06).'");
$this->addSql("COMMENT ON COLUMN storage_type.code IS 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).'");
$this->addSql("COMMENT ON COLUMN storage_type.label IS 'Libellé FR affiché du type de stockage (ex. « Cuve mélasse »).'");
// + COMMENT ON COLUMN sur les tables de jonction (product_site, product_storage_type, storage_type_site)
$this->addStandardTimestampableBlamableComments($schema, 'product');
```
### 3.3 Entité `Product` — squelette (extrait)
Pattern jumeau de `Category` (`#[Auditable]`, `TimestampableBlamableTrait`, soft delete). **Chaque propriété affichée porte un read-group** (RETEX M1).
```php
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\ProductProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\ProductProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository;
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (§ 2.1)
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['product:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
provider: ProductProvider::class,
),
new Get(
security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
provider: ProductProvider::class,
),
new Post(
security: "is_granted('catalog.products.manage')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['product:write']],
processor: ProductProcessor::class,
),
new Patch(
security: "is_granted('catalog.products.manage')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['product:write']],
provider: ProductProvider::class,
processor: ProductProcessor::class,
),
// Pas de Delete au M6 (docx) ; soft delete préparé non exposé (§ 2.7).
],
)]
#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)]
#[ORM\Table(name: 'product')]
#[Auditable]
class Product implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
#[Groups(['product:read'])]
private ?int $id = null;
/** Code produit (= « Numéro » liste), unique global, saisi (RG-6.01). */
#[ORM\Column(length: 50)]
#[Assert\NotBlank(message: 'Le code produit est obligatoire.')]
#[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.')]
#[Groups(['product:read', 'product:write'])]
private ?string $code = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Le nom du produit est obligatoire.')]
#[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.')]
#[Groups(['product:read', 'product:write'])]
private ?string $name = null;
/** États (multi-select) ⊆ {PURCHASE, SALE, OTHER}, ≥ 1 (RG-6.02). */
#[ORM\Column(type: 'json')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
#[Assert\All([new Assert\Choice(choices: ['PURCHASE', 'SALE', 'OTHER'], message: 'État de produit invalide.')])]
#[Groups(['product:read', 'product:write'])]
private array $states = [];
#[ORM\Column(options: ['default' => false])]
#[Groups(['product:read', 'product:write'])]
private bool $manufactured = false; // saisi si SALE, sinon false (RG-6.03)
#[ORM\Column(name: 'contains_molasses', options: ['default' => false])]
#[Groups(['product:read', 'product:write'])]
private bool $containsMolasses = false; // saisi si SALE, sinon false (RG-6.03)
#[ORM\ManyToOne(targetEntity: Category::class)]
#[ORM\JoinColumn(name: 'category_id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'La catégorie produit est obligatoire.')]
#[Groups(['product:read', 'product:write'])]
private ?Category $category = null; // type PRODUIT, validé Callback (RG-6.05)
/** @var Collection<int, Site> Sites de disponibilité (≥ 1, RG-6.04). */
#[ORM\ManyToMany(targetEntity: Site::class)]
#[ORM\JoinTable(name: 'product_site')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')]
#[Groups(['product:read', 'product:write'])]
private Collection $sites;
/** @var Collection<int, StorageType> Types de stockage (≥ 1 — RG-6.06, référentiel plat). */
#[ORM\ManyToMany(targetEntity: StorageType::class)]
#[ORM\JoinTable(name: 'product_storage_type')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
#[Groups(['product:read', 'product:write'])]
private Collection $storageTypes;
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $deletedAt = null; // soft delete, non exposé (§ 2.7)
public function __construct()
{
$this->sites = new ArrayCollection();
$this->storageTypes = new ArrayCollection();
}
// RG-6.03 (champs conditionnels SALE) + RG-6.05 (catégorie de type PRODUIT) :
// cohérence via #[Assert\Callback] (§ 7). RG-6.06 = simple Assert\Count(min:1)
// (référentiel plat, plus de contrainte de disponibilité par site).
// ... getters/setters ...
}
```
> ⚠ `Site` appartient au module Sites — on consomme son read-group (`site:read`), **pas de logique inter-module** (§ 2.1). `Category` / `StorageType` sont dans le **même** module `Catalog`.
## 4. API REST (API Platform)
### 4.0 Contrat de sérialisation (RETEX M1 — section critique)
> **Leçon M1→M5** : pour **chaque champ affiché** (liste OU détail), les **3 maillons** : (a) groupe sur la propriété, (b) groupe dans le `normalizationContext` de l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
**Contexte par opération** :
| Opération | `normalizationContext` (groupes) |
|---|---|
| `GetCollection` (liste) | `product:read` + `category:read` + `site:read` + `storage_type:read` + `default:read` |
| `Get` / `Post` / `Patch` (détail) | + `product:item:read` |
**LISTE — colonne datatable → maillons** (docx p.3 : Nom, Numéro, Catégorie) :
| Colonne affichée | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|---|---|---|---|
| Nom | `name``product:read` | ✅ | — |
| Numéro | `code``product:read` | ✅ | — |
| Catégorie | `category``product:read` (embed) | ✅ | `category:read` ✅ (affiche `category.name`) |
**DÉTAIL — maillons** : `states`, `manufactured`, `containsMolasses``product:read` ; `sites` (embed `site:read`) + `storageTypes` (embed `storage_type:read`) ∈ `product:read` (ensembles **bornés** → embed autorisé, ne viole pas la règle n°13). Rien de spécifique en `product:item:read` au-delà des relations (tout le produit tient en liste) — `product:item:read` réservé si on ajoute des champs détail-only ultérieurement.
### 4.0.bis Réponse JSON de référence (DoD — CAPTURÉ sur l'API réelle, ERP-203)
> **Definition of Done** (miroir M2→M5) : créer un produit via `POST /api/products`, appeler `GET /api/products` (liste) ET `GET /api/products/{id}` (détail), **coller la réponse JSON réelle** ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. Pièges re-testés : `category` en **objet embarqué** (pas IRI nu) ; `sites` / `storageTypes` en **tableaux d'objets** (pas tableaux d'IRI) ; `states` en tableau de chaînes ; `manufactured` / `containsMolasses` présents (booléens). `skip_null_values` actif → ne pas présumer la présence des champs null.
>
> **Capture réelle** (ERP-203) : produit créé par un `POST` réel puis relu, via `ProductSerializationContractTest` (régénérable : `PRODUCT_DOD_DUMP=1` → `/tmp/product-dod-{list,detail}.json`). Valeurs ci-dessous reformatées avec des libellés lisibles ; **les clés sont celles de la réponse réelle**. Écarts notables vs l'esquisse initiale, à connaître côté front :
> - La **LISTE porte déjà `sites` + `storageTypes` embarqués** (la propriété `product:read` est dans le contexte liste ET détail) : pas besoin d'un appel détail pour les obtenir.
> - `category` embarque **sa collection `categoryTypes`** (utile pour vérifier le type PRODUIT côté front, RG-6.05) **plus ses métadonnées d'audit** (`createdAt`/`updatedAt`/`createdBy`/`updatedBy`).
> - `createdBy` / `updatedBy` (produit et catégorie) sortent en **IRI** (`/api/me` pour l'utilisateur courant), pas en objet User embarqué.
> - chaque `site` embarque l'**adresse complète** (`street`, `postalCode`, `city`, `color`, `fullAddress` — groupe `site:read`).
> - un `StorageType` n'expose que `id` / `code` / `label` (sa relation `sites` n'est pas sérialisée — § 2.4).
**`GET /api/products` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`) :
```jsonc
{
"@context": "/api/contexts/Product",
"@id": "/api/products",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/products/34",
"@type": "Product",
"id": 34,
"code": "BLE-TENDRE-01",
"name": "Blé tendre",
"states": ["PURCHASE", "SALE"],
"manufactured": true,
"containsMolasses": true,
"category": {
"@id": "/api/categories/12",
"@type": "Category",
"id": 12,
"name": "Céréales",
"code": "CEREALES",
"categoryTypes": [
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
],
"createdAt": "2026-06-25T12:09:27+02:00",
"updatedAt": "2026-06-25T12:09:27+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me"
},
"sites": [
{
"@id": "/api/sites/1",
"@type": "Site",
"id": 1,
"name": "Chatellerault",
"code": "86",
"street": "14 All. d'Argenson",
"postalCode": "86100",
"city": "Châtellerault",
"color": "#056CF2",
"createdAt": "2026-06-25T11:32:33+02:00",
"updatedAt": "2026-06-25T11:32:33+02:00",
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
}
],
"storageTypes": [
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
],
"createdAt": "2026-06-25T12:09:28+02:00",
"updatedAt": "2026-06-25T12:09:28+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me"
}
],
"view": { "@id": "/api/products?search=BLE-TENDRE-01", "@type": "PartialCollectionView" }
}
```
**`GET /api/products/34` (DÉTAIL)** — **même structure** que la ligne de liste (les `sites` / `storageTypes` sont déjà embarqués en liste ; `product:item:read` est réservé à d'éventuels champs détail-only ultérieurs) :
```jsonc
{
"@context": "/api/contexts/Product",
"@id": "/api/products/34",
"@type": "Product",
"id": 34,
"code": "BLE-TENDRE-01",
"name": "Blé tendre",
"states": ["PURCHASE", "SALE"],
"manufactured": true,
"containsMolasses": true,
"category": {
"@id": "/api/categories/12",
"@type": "Category",
"id": 12,
"name": "Céréales",
"code": "CEREALES",
"categoryTypes": [
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
],
"createdAt": "2026-06-25T12:09:27+02:00",
"updatedAt": "2026-06-25T12:09:27+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me"
},
"sites": [
{
"@id": "/api/sites/1",
"@type": "Site",
"id": 1,
"name": "Chatellerault",
"code": "86",
"street": "14 All. d'Argenson",
"postalCode": "86100",
"city": "Châtellerault",
"color": "#056CF2",
"createdAt": "2026-06-25T11:32:33+02:00",
"updatedAt": "2026-06-25T11:32:33+02:00",
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
}
],
"storageTypes": [
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
],
"createdAt": "2026-06-25T12:09:28+02:00",
"updatedAt": "2026-06-25T12:09:28+02:00",
"createdBy": "/api/me",
"updatedBy": "/api/me"
}
```
### 4.1 Query params (LISTE)
| Param | Effet |
|---|---|
| `?page` / `?itemsPerPage` | pagination standard (10 / 25 / 50, défaut 10) |
| `?search=` | recherche sur `code` et `name` |
| `?categoryId=` ou `?categoryCode=` | filtre par catégorie (drawer « Filtrer », docx p.3) |
| `?state=` | filtre par état (PURCHASE / SALE / OTHER) — drawer « Filtrer » |
| `?siteId[]=` | filtre par site de disponibilité |
| `?order[name]=asc` | tri (défaut : `name ASC`) |
Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\Doctrine\Orm\Paginator`, jamais d'array brut.
### 4.2 Référentiel `StorageType` — `GET /api/storage_types`
- Opérations : `GetCollection` + `Get` (lecture seule au M6 — § 2.4).
- Sécurité : `is_granted('catalog.products.view')` (référentiel servant le formulaire produit). *(Si un autre rôle doit lire ce référentiel sans accès produit, ajouter une `read_ref` dédiée — non requis au M6 vu le RBAC admin-only.)*
- Référentiel **plat** : renvoie TOUS les types (plus de paramètre `?siteId[]=` — RG-6.06 revue, § 2.4).
- **`?pagination=false`** : échappatoire select (référentiel borné ≤ quelques dizaines) — règle frontend.
- `normalizationContext: ['storage_type:read']` ; tri `label ASC`.
### 4.3 `POST /api/products` (création)
- Le client envoie : `code`, `name`, `states[]`, `manufactured`, `containsMolasses`, `category` (IRI), `sites[]` (IRI), `storageTypes[]` (IRI).
- Le **Processor** (`ProductProcessor`) :
1. Normalise `code` (trim + UPPER) et `name` (trim) — RG-6.07.
2. Valide l'**unicité globale** du `code` parmi les actifs → **409** sur doublon (RG-6.01).
3. Force `manufactured` / `containsMolasses` à `false` si `states` ne contient pas `SALE` (RG-6.03).
4. Valide que `category` est de type **PRODUIT** (RG-6.05) → 422 sinon. `storageTypes` : `≥ 1` (RG-6.06, référentiel plat — plus de contrainte de disponibilité par site).
- Réponse `201` avec le produit complet.
### 4.4 `PATCH /api/products/{id}` (modification)
- Mise à jour partielle, mêmes règles. Le **mode strict PATCH** s'applique (RETEX M1) : un champ hors-permission dans le payload = 403 global (ici un seul niveau `manage`, donc surface réduite).
- Re-validation unicité `code` (en excluant le produit courant). Re-force des conditionnels (RG-6.03).
### 4.5 Export — `GET /api/products/export.xlsx`
- Exporte **toute la liste** des produits (docx : bouton « Exporter » → « Exporte toute la liste des produits »), filtres actifs appliqués.
- Colonnes : Numéro (`code`), Nom, États (Achat/Vendu/Autre joints), Catégorie, Sites, Types de stockage, Fabriqué, Contient mélasse.
- Génération via le helper XLSX standard projet (skill `xlsx`) — controller dédié (miroir `ClientExportController`) OU provider binaire ; **whitelisté pagination** (`EXCLUDED`) car export complet.
## 5. RBAC, module & sidebar
### 5.1 `CatalogModule::permissions()` — ajout
```php
// Ajouts M6 (à insérer dans CatalogModule::permissions()) :
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
```
Synchronisation : `app:sync-permissions`.
### 5.2 Matrice rôle → permissions (docx p.3 — admin-only, C7)
| Rôle | `…products.view` | `…products.manage` |
|---|:--:|:--:|
| **Admin** | ✅ | ✅ |
| **Bureau** | ❌ | ❌ |
| **Compta** | ❌ | ❌ |
| **Commerciale** | ❌ | ❌ |
| **Usine** | ❌ | ❌ |
> Très restrictif : le Catalogue produit est **admin-only** (docx). Item sidebar masqué pour tous les autres rôles. (Si plus tard Bureau doit consulter, ajouter `catalog.products.view` à son rôle dans les 3 miroirs.)
### 5.3 Sidebar (`config/sidebar.php`)
Nouvel item dans la **section « Administration » existante**, placé **juste sous « Répertoire transporteurs »** (`/carriers`) — DÉCISION Matthieu (24/06) :
```php
[
'label' => 'sidebar.catalog.products',
'to' => '/admin/products',
'icon' => 'mdi:package-variant-closed',
'module' => 'catalog',
'permission' => 'catalog.products.view',
],
```
### 5.4 Règle ABSOLUE n°8 — 3 miroirs RBAC
Toute permission `catalog.products.*` doit être posée **simultanément** dans :
1. `config/sidebar.php` (item + permission ci-dessus),
2. `frontend/tests/e2e/_fixtures/personas.ts` (le persona **Admin** gagne `catalog.products.view/manage` + `expectedAdminLinks` ; les personas métier **ne** gagnent **rien**),
3. `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` (miroir back du même persona Admin).
## 6. Normalisation serveur (RG-6.07)
`ProductFieldNormalizer` (miroir `CategoryProcessor` / `CarrierFieldNormalizer`), appelé par le Processor avant validation :
- `code` → trim + UPPER (cohérent avec la stratégie de codes stables du Catalog).
- `name` → trim (rejet 422 si vide après trim — RG-6.01/6.02 sur le name de Category, même garde-fou).
## 7. Règles de gestion (RG)
| RG | Source | Énoncé |
|---|---|---|
| **RG-6.01** | docx+back | `code` produit (= « Numéro » liste) obligatoire, **unique global** parmi les actifs, normalisé (trim/UPPER), **409** sur doublon. |
| **RG-6.02** | docx+back | `states` = multi-select ⊆ {`PURCHASE`,`SALE`,`OTHER`}, **≥ 1** obligatoire (CHECK non-vide + `Assert\Count(min:1)`). |
| **RG-6.03** | docx+back | « Fabriqué » et « Contient de la mélasse » saisissables **uniquement si `states` contient `SALE`** ; sinon forcés `false` serveur. |
| **RG-6.04** | docx | `sites` (multi-select) obligatoire, **≥ 1** site. |
| **RG-6.05** | docx+back | `category` obligatoire, limitée aux catégories de **type PRODUIT** (select filtré `?typeCode=PRODUIT` + validation Callback 422). |
| **RG-6.06** | docx+back | `storageTypes` (multi-select) obligatoire, **≥ 1**. Référentiel `StorageType` **plat** (tous les types, **plus de filtrage par site** — décision 26/06, § 2.4) et **provisoire** (en attente Aurore). |
| **RG-6.07** | back | Normalisation serveur : `code` trim+UPPER, `name` trim (§ 6). |
| **RG-6.08** | back | « Modification » = même formulaire/mêmes règles que « Ajouter » ; bouton « Valider » → « Enregistrer » (docx p.7). Code & contraintes inchangés. |
| **RG-6.09** | back | Liste : exclut par défaut les produits soft-deleted ; pas de Delete exposé (§ 2.7). |
| **RG-6.10** | back+front | Onglets « Fournisseurs » / « Clients » = **hors périmètre V0** (placeholder), dépendent du module Contrat inexistant (HP-M6-01). **Front** : (a) NON affichés à l'ajout — n'apparaissent qu'après validation du formulaire principal (écran de modification) ; (b) visibilité conditionnée par l'état (cf. C3, « Aucun » = `OTHER`) : « Fournisseurs » si `PURCHASE` ou `OTHER`, « Clients » si `SALE` ou `OTHER`. |
Cohérence inter-champs (RG-6.03 / 6.05) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres (non-vacuité `states`). RG-6.06 : simple `Assert\Count(min:1)` (référentiel plat, plus de validation de disponibilité par site).
## 8. Tests (PHPUnit) — `make test`
- **`ProductSerializationContractTest`** : capture JSON liste + détail (DoD § 4.0.bis) ; `category`/`sites`/`storageTypes` embarqués (objets, pas IRI) ; `states` tableau ; booléens présents.
- **`ProductCodeUniquenessTest`** : 409 sur doublon de `code` (actifs) ; réutilisation possible d'un code soft-deleted (index partiel).
- **`ProductStatesValidationTest`** : ≥ 1 état (RG-6.02) ; valeurs hors enum rejetées.
- **`ProductConditionalFieldsTest`** : `manufactured`/`containsMolasses` forcés `false` si pas `SALE` (RG-6.03).
- **`ProductCategoryTypeTest`** : 422 si `category` n'est pas de type PRODUIT (RG-6.05).
- ~~**`ProductStorageTypeBySiteTest`**~~ : supprimé — `StorageType` est un référentiel plat (plus de disponibilité par site, RG-6.06 revue, § 2.4).
- **RBAC** : Admin OK ; Bureau/Compta/Commerciale/Usine → 403 (view + manage).
- **Architecture** (déjà en place, ne pas casser) : `ColumnsHaveSqlCommentTest`, `EntitiesAreTimestampableBlamableTest` (whitelister `StorageType`), `AuditableEntitiesHaveI18nLabelTest` (`catalog_product`), `CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
## 9. Hors périmètre (HP)
| Réf | Sujet |
|---|---|
| **HP-M6-01** | **Onglets « Fournisseurs » et « Clients »** du produit (liaison à des **contrats** client/fournisseur, « clients en prestation de triage », « contrats TAF »). Dépend d'un **module Contrat inexistant**. Rendus en **placeholder « en cours de développement »** au M6 (§ 1.bis C8, RG-6.10). À spécifier quand le module Contrat existera. |
| **HP-M6-02** | Liste **définitive des types de stockage** (fournie par Aurore). Re-seed du référentiel `StorageType` (§ 2.4). La disponibilité par site relèvera du futur module **Stockage** (un stockage = 1 site + 1 type), pas de ce référentiel. |
| **HP-M6-03** | **CRUD admin du référentiel `StorageType`** (création/édition par un admin). Au M6 : lecture seule + seed. |
| **HP-M6-04** | Archivage / suppression d'un produit (non prévu au docx — soft delete préparé mais non exposé, § 2.7). |
| **HP-M6-05** | Contrainte SQL « `category` de type PRODUIT » au niveau base (au M6 : validation applicative seulement, § 2.5). |
## 10. Tickets Lesstime (à découper — back en tête)
| Ordre | Sujet | Tag |
|---|---|---|
| 0 | Permissions `catalog.products.view/manage` + sidebar (item sous Transporteurs) + 3 miroirs RBAC | Backend |
| 1 | Migration : `storage_type` (+ jonction site) + `product` (+ jonctions) + seed type PRODUIT + COMMENT | Backend |
| 2 | Entités `Product` + `StorageType` + Repositories + contrat sérialisation | Backend |
| 3 | `ProductProvider` + `ProductProcessor` (unicité code, RG-6.03/6.05/6.06, normalisation) | Backend |
| 4 | Référentiel `StorageType` exposé (`GetCollection` + filtre `?siteId[]`) + seed Figma + catégories PRODUIT | Backend |
| 5 | Export XLSX | Backend |
| 6 | Tests PHPUnit RG-6.01→6.10 + capture contrat JSON | Backend |
| 7 | Page liste `/admin/products` (usePaginatedList) + drawer filtre + export | Frontend |
| 8 | Écran Ajouter (champs conditionnels SALE, selects filtrés catégorie/stockage) | Frontend |
| 8.bis | Écran **Consultation** (lecture seule) `/admin/products/{id}` : clic sur une ligne → consultation (pas l'édition directe), bouton « Modifier » → édition. **Règle ERP-193 (calque client/fournisseur)** : champs vides + checkbox non cochées masqués, et **onglets vides masqués** → les coquilles Fournisseurs/Clients (placeholder, module Contrat inexistant) ne sont **pas affichées en consultation** (elles restent visibles à l'édition). | Frontend |
| 9 | Écran Modification (« Enregistrer ») + onglets **placeholder « en cours de dev »** (Fournisseurs / Clients) | Frontend |
| 10 | i18n + libellé audit (`catalog_product`) | Frontend |
## 📦 Tickets Lesstime générés
**TaskGroup Lesstime** : **#36 — M6 — Catalogue produit** (projet `ERP / Starseed`, projectId=6) — créé le 24/06/2026, 11 tickets au statut « Prêt à dev ». Back = **Matthieu**, Front = **Tristan**. Chaque ticket porte son prompt d'implémentation `.md` en pièce jointe (dossier `prompts/`).
| # | ERP | Ticket | Effort | Tag | Assigné |
|---|---|---|---|---|---|
| 1.1 | ERP-197 | Permissions catalog.products.* + sidebar + 3 miroirs RBAC | S | Backend | Matthieu |
| 1.2 | ERP-198 | Migrer le schéma M6 (storage_type, product, jonctions, type PRODUIT) | M | Backend | Matthieu |
| 1.3 | ERP-199 | Entités Product + StorageType + repositories + contrat sérialisation | M | Backend | Matthieu |
| 1.4 | ERP-200 | ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06) | L | Backend | Matthieu |
| 1.5 | ERP-201 | Exposer le référentiel StorageType + seed Figma + catégories PRODUIT | M | Backend | Matthieu |
| 1.6 | ERP-202 | Export XLSX des produits | S | Backend | Matthieu |
| 1.7 | ERP-203 | Tests PHPUnit RG-6.01→6.10 + capture du contrat JSON | M | Backend | Matthieu |
| 1.8 | ERP-204 | Page liste /admin/products (datatable, filtre, export) | M | Frontend | Tristan |
| 1.9 | ERP-205 | Écran Ajouter un produit (champs conditionnels, selects filtrés) | L | Frontend | Tristan |
| 1.10 | ERP-206 | Écran Modification + onglets placeholder (Fournisseurs/Clients) | M | Frontend | Tristan |
| 1.11 | ERP-207 | i18n + libellé audit catalog_product | S | Frontend | Tristan |
@@ -1,353 +0,0 @@
# ERP-208 — Fix ticket de pesée — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en cases à cocher (`- [ ]`).
**Goal:** Ajouter le nom du tiers dans un cartouche bordé en haut à droite du bon de pesée PDF, et filtrer les listes Client/Fournisseur du formulaire de ticket sur le site courant (avec recharge au changement de site).
**Architecture:** Le filtre back `?siteId[]=` existe déjà sur `/clients` et `/suppliers` (joint adresses→sites) → point 2 = front uniquement. Point 1 = une méthode entité `getCounterpartyName()` + refonte du header du template Twig en table 2 colonnes (Dompdf = CSS 2.1).
**Tech Stack:** PHP 8.4 / Symfony / API Platform / Doctrine / Twig + Dompdf ; Nuxt 4 / Vue 3 / Vitest.
## Global Constraints
- `declare(strict_types=1);` en tête de tout fichier PHP.
- Commentaires en **français**, code (noms) en anglais.
- Front : `useApi()` uniquement, composants `Malio*`, 4 espaces, TS strict.
- Dompdf : **CSS 2.1 uniquement** (pas de flex/grid) → mise en page par tableaux.
- **Aucun commit sans demande explicite de Tristan** (les étapes « commit » sont différées en fin de chantier, sur demande).
- Vérif finale : `make test` + `make nuxt-test` + `make php-cs-fixer-allow-risky`. Pas d'E2E.
---
### Task 1 : `WeighingTicket::getCounterpartyName()` (back)
**Files:**
- Modify: `src/Module/Logistique/Domain/Entity/WeighingTicket.php` (ajout méthode près de `getOtherLabel`, ~ligne 449)
- Test: `tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` (create)
**Interfaces:**
- Produces: `WeighingTicket::getCounterpartyName(): ?string` — companyName du client/fournisseur ou otherLabel selon `counterpartyType`, null sinon. Consommé par le template Twig (Task 2).
- [ ] **Step 1 : test qui échoue**
```php
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Domain;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use PHPUnit\Framework\TestCase;
final class WeighingTicketCounterpartyNameTest extends TestCase
{
public function testReturnsClientCompanyNameForClientCounterparty(): void
{
$client = (new Client())->setCompanyName('Ferme du Pré');
$ticket = (new WeighingTicket())->setCounterpartyType('CLIENT')->setClient($client);
self::assertSame('Ferme du Pré', $ticket->getCounterpartyName());
}
public function testReturnsSupplierCompanyNameForSupplierCounterparty(): void
{
$supplier = (new Supplier())->setCompanyName('Coop Sud');
$ticket = (new WeighingTicket())->setCounterpartyType('FOURNISSEUR')->setSupplier($supplier);
self::assertSame('Coop Sud', $ticket->getCounterpartyName());
}
public function testReturnsOtherLabelForOtherCounterparty(): void
{
$ticket = (new WeighingTicket())->setCounterpartyType('AUTRE')->setOtherLabel('Particulier');
self::assertSame('Particulier', $ticket->getCounterpartyName());
}
public function testReturnsNullWhenNoCounterparty(): void
{
self::assertNull((new WeighingTicket())->getCounterpartyName());
}
}
```
- [ ] **Step 2 : lancer le test → échec**
`make test` filtré : `docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php`
Attendu : FAIL (`getCounterpartyName` n'existe pas). Vérifier au passage que `Client`/`Supplier` ont bien un constructeur sans argument et `setCompanyName` (sinon adapter l'instanciation du test au pattern existant des entités).
- [ ] **Step 3 : implémentation minimale**
Dans `WeighingTicket.php`, après `getOtherLabel()`/`setOtherLabel()` :
```php
/**
* Nom du tiers à afficher (bon de pesée PDF, ERP-208) : raison sociale du
* client/fournisseur ou libellé libre selon le type de contrepartie (RG-5.03).
* Null si aucune contrepartie cohérente (brouillon).
*/
public function getCounterpartyName(): ?string
{
return match ($this->counterpartyType) {
'CLIENT' => $this->client?->getCompanyName(),
'FOURNISSEUR' => $this->supplier?->getCompanyName(),
'AUTRE' => $this->otherLabel,
default => null,
};
}
```
- [ ] **Step 4 : lancer le test → succès**
`docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` → PASS.
---
### Task 2 : Cartouche tiers dans le template PDF
**Files:**
- Modify: `templates/logistique/weighing_ticket_print.html.twig`
**Interfaces:**
- Consumes: `ticket.counterpartyName` (Task 1).
- [ ] **Step 1 : ajouter le style du cartouche + header 2 colonnes**
Dans le `<style>`, ajouter :
```css
.header { width: 100%; border-collapse: collapse; }
.header td { vertical-align: top; }
.header .h-right { text-align: right; }
.party-box { display: inline-block; border: 1px solid #000; padding: 8px 12px; min-width: 160px; text-align: center; font-weight: bold; font-size: 12px; }
```
- [ ] **Step 2 : remplacer le bloc logo + identité par une table 2 colonnes**
Remplacer (logo + 3 lignes company) par :
```twig
<table class="header">
<tr>
<td>
{% if logoSrc %}
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
{% endif %}
<div class="company-name">SA LIOT Châtellerault</div>
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div>
<div class="company-line">RCS Châtellerault B 339 505 612</div>
</td>
<td class="h-right">
{% if ticket.counterpartyName %}
<div class="party-box">{{ ticket.counterpartyName }}</div>
{% endif %}
</td>
</tr>
</table>
```
(Le `.title` « Ticket de pesée » et la suite restent inchangés, sous la table.)
- [ ] **Step 3 : vérifier le rendu PDF**
Le test existant `WeighingTicketPrintApiTest` doit rester vert :
`docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Api/WeighingTicketPrintApiTest.php` → PASS (`%PDF`, content-type, disposition inchangés).
---
### Task 3 : `useWeighingTicketReferentials.load(siteId?)` (front)
**Files:**
- Modify: `frontend/modules/logistique/composables/useWeighingTicketReferentials.ts`
- Test: `frontend/modules/logistique/composables/__tests__/useWeighingTicketReferentials.spec.ts` (create)
**Interfaces:**
- Produces: `load(siteId?: number | null): Promise<void>` — passe `siteId[]=<siteId>` aux fetch `/clients` et `/suppliers` quand `siteId` est fourni ; sinon comportement actuel (liste complète).
- [ ] **Step 1 : test qui échoue**
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const getMock = vi.fn()
vi.stubGlobal('useApi', () => ({ get: getMock }))
import { useWeighingTicketReferentials } from '~/modules/logistique/composables/useWeighingTicketReferentials'
describe('useWeighingTicketReferentials', () => {
beforeEach(() => {
getMock.mockReset()
getMock.mockResolvedValue({ member: [] })
})
it('passe siteId[] aux deux endpoints quand un site est fourni', async () => {
const { load } = useWeighingTicketReferentials()
await load(7)
const clientsCall = getMock.mock.calls.find(c => c[0] === '/clients')
const suppliersCall = getMock.mock.calls.find(c => c[0] === '/suppliers')
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
expect(suppliersCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
})
it('ne passe pas siteId[] quand aucun site (liste complète)', async () => {
const { load } = useWeighingTicketReferentials()
await load(null)
const clientsCall = getMock.mock.calls.find(c => c[0] === '/clients')
expect(clientsCall?.[1]).not.toHaveProperty('siteId[]')
})
})
```
- [ ] **Step 2 : lancer → échec**
`make nuxt-test` (ou ciblé) → FAIL (`load` n'accepte pas d'argument / `siteId[]` absent).
- [ ] **Step 3 : implémentation**
Modifier `fetchAll` et `load` :
```ts
/** Récupère une collection complète (pagination désactivée) en Hydra, filtrée site si fourni. */
async function fetchAll(url: string, siteId?: number | null): Promise<PartyMember[]> {
const query: Record<string, unknown> = { pagination: 'false' }
// Filtre par site courant (ERP-208) : un tiers est rattaché à un site via
// les sites de ses adresses. Param `siteId[]` déjà géré par les providers M1/M2.
if (siteId !== null && siteId !== undefined) {
query['siteId[]'] = [siteId]
}
const res = await api.get<{ member?: PartyMember[] }>(
url,
query,
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
async function load(siteId?: number | null): Promise<void> {
await Promise.allSettled([
fetchAll('/clients', siteId).then((list) => {
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
}),
fetchAll('/suppliers', siteId).then((list) => {
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
}),
])
}
```
- [ ] **Step 4 : lancer → succès**
`make nuxt-test` ciblé sur le spec → PASS.
---
### Task 4 : Brancher site courant + recharge dans new.vue et edit.vue (front)
**Files:**
- Modify: `frontend/modules/logistique/pages/weighing-tickets/new.vue`
- Modify: `frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue`
- Test: `frontend/modules/logistique/pages/__tests__/weighingTicketNew.spec.ts` (étendre)
**Interfaces:**
- Consumes: `useCurrentSite().currentSite` (ref `Site | null`), `useWeighingTicketReferentials().load(siteId?)`, `form.clientIri` / `form.supplierIri` / `referentials.clients` / `referentials.suppliers`.
- [ ] **Step 1 : helper de reset partagé**
Logique commune aux deux pages : après recharge, vider le tiers sélectionné s'il n'est plus dans les options. Implémenté inline dans chaque page (2 lignes) — pas de nouveau composable pour si peu.
- [ ] **Step 2 : new.vue — brancher currentSite + watch**
Remplacer le bloc `onMounted` final :
```ts
const { currentSite } = useCurrentSite()
/** Recharge les référentiels pour le site donné puis purge le tiers devenu hors-site (ERP-208). */
async function reloadReferentials(siteId: number | null): Promise<void> {
await referentials.load(siteId)
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
form.clientIri.value = null
}
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
form.supplierIri.value = null
}
}
onMounted(() => {
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
})
// Changement de site pendant la saisie → recharge les listes du nouveau site (ERP-208).
watch(() => currentSite.value?.id, (siteId) => {
reloadReferentials(siteId ?? null).catch(() => {})
})
```
Ajouter `watch` à l'import `vue` et `useCurrentSite` (auto-importé Nuxt — sinon import explicite `~/modules/sites/composables/useCurrentSite`).
- [ ] **Step 3 : edit.vue — même branchement**
Adapter le `onMounted` async existant (qui fait aussi `fetchTicket`/`hydrate`) :
```ts
const { currentSite } = useCurrentSite()
async function reloadReferentials(siteId: number | null): Promise<void> {
await referentials.load(siteId)
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
form.clientIri.value = null
}
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
form.supplierIri.value = null
}
}
onMounted(async () => {
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
try {
const detail = await fetchTicket(ticketId)
ticketNumber.value = detail.number ?? ''
form.hydrate(detail)
}
catch {
error.value = true
}
finally {
loading.value = false
}
})
watch(() => currentSite.value?.id, (siteId) => {
reloadReferentials(siteId ?? null).catch(() => {})
})
```
- [ ] **Step 4 : étendre le spec front**
Dans `weighingTicketNew.spec.ts`, ajouter un cas vérifiant que `load` est appelé avec l'id du site courant au montage (mock `useCurrentSite` retournant un `currentSite` avec `id`). Adapter au style de mock déjà en place dans le fichier.
- [ ] **Step 5 : lancer les tests front**
`make nuxt-test` → PASS (specs new/edit + referentials).
---
## Vérification finale
- [ ] `make test` (back) — vert.
- [ ] `make nuxt-test` (front) — vert.
- [ ] `make php-cs-fixer-allow-risky` — pas de diff non voulu.
- [ ] **STOP** : remettre la main à Tristan pour les tests manuels (impression PDF + switch de site). Commits différés jusqu'à sa demande.
## Self-review (couverture spec)
- Point 1 (cartouche PDF nom seul) → Task 1 + Task 2. ✓
- Point 2 (filtre site + recharge au switch + reset-si-absent) → Task 3 + Task 4. ✓
- Définition « lié au site » via adresses → param `siteId[]` (back déjà OK). ✓
- Portée ticket-seulement (pas de modif répertoires) → on n'édite que le composable du ticket + ses pages. ✓
- Pas de migration / RBAC / E2E. ✓
@@ -1,124 +0,0 @@
# ERP-208 — Fix ticket de pesée
> Module : **Logistique (M5)** — écrans « Ajouter / Modifier un ticket de pesée » + bon de pesée imprimé (PDF).
> Branche : `fix/erp-208-ticket-pesee`.
> Date : 2026-06-25.
## 1. Contexte
Le ticket de pesée (M5) est implémenté (ERP-181 → ERP-193). Deux retours client sont
regroupés dans ce fix :
1. **Bon de pesée PDF** : il manque un **cartouche bordé en haut à droite** de la
page contenant le **nom du tiers** (client / fournisseur / champ « autre »). Le
PDF actuel n'affiche que l'identité société (SA LIOT) en haut à gauche.
2. **Écran de saisie** : quand l'utilisateur a **plusieurs sites autorisés**, les
listes déroulantes Client / Fournisseur doivent être **filtrées sur le site
courant** (le tiers est rattaché à un site via les sites de ses adresses), et
**rechargées si l'utilisateur change de site** en restant sur la page.
## 2. État du code existant (constats de cadrage)
- **Le tiers n'a pas de site en propre.** Client (M1) et Supplier (M2) sont
rattachés à un site **via les sites de leurs adresses** (`getSites()` agrège ;
RG-2.06). « Lié au site » = a au moins une adresse rattachée à ce site.
- **Le filtre back existe déjà.** `ClientProvider` / `SupplierProvider` lisent un
filtre répétable `?siteId[]=<id>` (drawers des répertoires M1/M2) et le délèguent
à `createListQueryBuilder(..., array $siteIds, ...)``applySiteIds()` qui joint
`addresses → sites` (`site3.id IN (:siteIds)` / `site4.id IN (:siteIds)`).
**Aucun travail back n'est nécessaire pour le filtre.**
- **La donnée du PDF est déjà chargée.** `DoctrineWeighingTicketRepository::findById()`
fetch-joine `client` et `supplier` ; le `WeighingTicketPrintProvider` charge le
ticket par cette méthode. Le template a donc accès au nom du tiers.
- **Le changement de site est global** (`SiteSelector` header → `useCurrentSite.switchSite`
`PATCH /me/current-site` + `loadSidebar()` + `refreshNuxtData()`). `currentSite`
est un ref singleton de module. Les référentiels du ticket sont chargés en
`onMounted` **uniquement** (pas via `useAsyncData`) → ils ne se rechargent pas au
switch : **c'est le bug du point 2.**
- Le template PDF (`templates/logistique/weighing_ticket_print.html.twig`) est rendu
par Dompdf → **CSS 2.1 uniquement (pas de flexbox/grid)**, mise en page par tableaux.
## 3. Décisions (validées avec Tristan)
| Sujet | Décision |
|---|---|
| Définition « lié au site » | Tiers ayant ≥ 1 adresse rattachée au site sélectionné (via les adresses). |
| Portée du filtre | **Ticket de pesée seulement.** On ne modifie PAS le comportement des répertoires M1/M2 (déjà validés). On réutilise le param `?siteId[]=` existant côté front. |
| Switch de site avec tiers sélectionné | **Reset si absent** : après rechargement, si le tiers sélectionné n'est plus dans la liste du nouveau site, on vide sa valeur (le type de contrepartie reste). S'il y est encore, on le garde. |
| Contenu du cartouche PDF | **Nom seul** (pas de libellé « Client » / « Fournisseur » au-dessus). |
## 4. Conception
### 4.1 Point 1 — Cartouche tiers sur le bon de pesée (back + template)
**a. Résolution du nom — `WeighingTicket::getCounterpartyName(): ?string`**
Nouvelle méthode sur l'entité qui retourne, selon `counterpartyType` :
- `CLIENT``client?->getCompanyName()`
- `FOURNISSEUR``supplier?->getCompanyName()`
- `AUTRE``otherLabel`
- défaut → `null`
Rationale : garde le Twig « bête » (un seul `{{ ticket.counterpartyName }}`) et rend
la logique testable unitairement, sans toucher le provider ni le renderer.
**b. Template `weighing_ticket_print.html.twig`**
Passer le bloc d'en-tête en **table 2 colonnes** (contrainte Dompdf CSS 2.1) :
- colonne gauche (`width:auto`, `vertical-align:top`) : logo + identité société
(contenu **inchangé**) ;
- colonne droite (`text-align:right`, `vertical-align:top`) : un cartouche
`border:1px solid #000; padding:8px;` (largeur fixe, ~200px) contenant
`{{ ticket.counterpartyName }}` (nom seul, en gras).
Le reste du template (titre, table des pesées, poids net) est inchangé.
Cas `counterpartyName` null : en pratique l'impression a lieu après validation, où la
contrepartie est requise (groupe `finalize`). Par robustesse, si null → ne pas rendre
le cartouche (pas de cadre vide).
**c. Provider / renderer** : aucun changement (relations déjà fetch-jointes).
### 4.2 Point 2 — Listes filtrées par site + recharge au switch (front uniquement)
**a. `useWeighingTicketReferentials.ts`**
`load()` accepte un identifiant de site optionnel et l'injecte comme `siteId[]` dans
les requêtes `/clients` et `/suppliers` (en plus de `pagination=false`) :
- site fourni → `{ pagination: 'false', 'siteId[]': [siteId] }` ;
- site absent (`null`) → comportement actuel (liste complète, dégradé gracieux).
**b. Pages `weighing-tickets/new.vue` et `weighing-tickets/[id]/edit.vue`**
- récupèrent `currentSite` via `useCurrentSite()` ;
- `onMounted``referentials.load(currentSite.value?.id ?? null)` ;
- `watch(currentSite)` (sur l'id) → `referentials.load(newId)` puis **reset-si-absent** :
- si `form.clientIri` est défini et absent de `referentials.clients``form.clientIri = null` ;
- si `form.supplierIri` est défini et absent de `referentials.suppliers``form.supplierIri = null` ;
- `counterpartyType` et `otherLabel` ne sont pas touchés.
Note : le reset s'appuie sur les options (IRI `@id`) renvoyées par le référentiel ;
la comparaison se fait sur `value` (l'IRI Hydra).
**c. Cohérence avec la liste des tickets** : la liste `/weighing_tickets` est déjà
cloisonnée par site (provider M5). Filtrer les selects sur le site courant aligne la
saisie sur la liste.
## 5. Tests & vérification
| Niveau | Test | Contenu |
|---|---|---|
| Back (PHPUnit) | unitaire `WeighingTicket::getCounterpartyName()` | 3 cas : CLIENT → companyName, FOURNISSEUR → companyName, AUTRE → otherLabel ; + null si type absent. |
| Back | `WeighingTicketPrintApiTest` (existant) | reste vert (`%PDF`, content-type, disposition). |
| Front (Vitest) | `weighingTicketNew.spec.ts` / `weighingTicketEdit.spec.ts` | `load` passe `siteId[]` quand un site courant existe ; au changement de `currentSite` → rechargement + reset-si-absent du tiers sélectionné. |
Commandes : `make test` + `make nuxt-test` + `make php-cs-fixer-allow-risky`.
Pas de test E2E (règle d'or : Vitest privilégié).
## 6. Hors périmètre / non-objectifs
- Pas de modification du comportement des répertoires Clients / Fournisseurs (M1/M2).
- Pas de nouvelle permission RBAC, pas de migration, pas de changement de schéma.
- Pas de cloisonnement par site « global » sur `/clients` et `/suppliers` (rejeté :
on garde le filtre opt-in via `?siteId[]`).
- L'identité société du PDF reste fixe (décision ERP-192, ne change pas selon le site).
-51
View File
@@ -21,45 +21,6 @@
<template #logo-collapsed> <template #logo-collapsed>
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/> <img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
</template> </template>
<!-- Footer deplie : compte connecte (survol -> deconnexion) + version. -->
<template #footer>
<div class="flex flex-col gap-2">
<!-- Bloc compte : au survol, un menu de deconnexion s'ouvre vers
le haut (le footer etant colle en bas de la sidebar). -->
<div class="group relative" data-test="sidebar-account">
<button
type="button"
data-test="sidebar-logout"
class="invisible absolute bottom-full left-0 right-0 mb-2 flex items-center gap-2 rounded-md bg-white px-3 py-2 text-[14px] font-semibold text-m-danger opacity-0 shadow-lg ring-1 ring-m-border transition-all duration-150 hover:bg-m-danger hover:text-white group-hover:visible group-hover:opacity-100"
@click="onLogout"
>
<Icon name="mdi:logout" class="size-[18px] shrink-0"/>
<span>{{ t('sidebar.account.logout') }}</span>
</button>
<div class="flex items-center gap-2 rounded-md p-1.5 text-black transition-colors group-hover:bg-m-primary/10 group-hover:font-semibold group-hover:text-m-primary">
<span class="flex size-9 shrink-0 items-center justify-center rounded-full bg-m-primary text-[13px] font-bold uppercase text-white">{{ initials }}</span>
<span class="min-w-0 flex-1 truncate text-[14px] font-semibold">{{ username }}</span>
<Icon name="mdi:chevron-up" class="size-[18px] shrink-0"/>
</div>
</div>
<p v-if="version" class="text-center text-[12px] font-bold text-m-muted">v {{ version }}</p>
</div>
</template>
<!-- Footer replie : pastille initiale, survol -> icone deconnexion. -->
<template #footer-collapsed>
<button
type="button"
data-test="sidebar-logout"
:title="`${username} — ${t('sidebar.account.logout')}`"
class="group mx-auto flex size-9 items-center justify-center rounded-full bg-m-primary text-[13px] font-bold uppercase text-white transition-colors hover:bg-m-danger"
@click="onLogout"
>
<span class="group-hover:hidden">{{ initials }}</span>
<Icon name="mdi:logout" class="hidden size-[18px] group-hover:block"/>
</button>
</template>
</MalioSidebar> </MalioSidebar>
<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">
@@ -81,18 +42,6 @@ const {isModuleActive} = useModules()
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute() const route = useRoute()
// Footer de la sidebar : compte connecte + deconnexion inline + version.
const {logout: onLogout} = useLogout()
const {version, load: loadAppVersion} = useAppVersion()
const username = computed(() => auth.user?.username ?? '')
// Pastille avatar : 1re lettre du compte (meme convention que la maquette Malio).
const initials = computed(() => username.value.charAt(0).toUpperCase() || '?')
onMounted(() => {
void loadAppVersion()
})
// Le SiteSelector est rendu si : // Le SiteSelector est rendu si :
// - le module Sites est actif dans config/modules.php (sinon la feature // - le module Sites est actif dans config/modules.php (sinon la feature
// n'a pas de sens, cf. ticket 3 spec criteres d'acceptation) ; // n'a pas de sens, cf. ticket 3 spec criteres d'acceptation) ;
+9 -160
View File
@@ -52,9 +52,7 @@
"admin": "Sites" "admin": "Sites"
}, },
"catalog": { "catalog": {
"categories": "Gestion des catégories", "categories": "Gestion des catégories"
"products": "Catalogue produits",
"storages": "Gestion des stockages"
} }
}, },
"dashboard": { "dashboard": {
@@ -127,7 +125,7 @@
}, },
"edit": { "edit": {
"title": "Modifier le fournisseur", "title": "Modifier le fournisseur",
"back": "Retour à la consultation", "back": "Retour au répertoire",
"loading": "Chargement du fournisseur…", "loading": "Chargement du fournisseur…",
"notFound": "Fournisseur introuvable.", "notFound": "Fournisseur introuvable.",
"save": "Enregistrer" "save": "Enregistrer"
@@ -185,7 +183,6 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"accounting": { "accounting": {
"infoTitle": "Informations",
"siren": "SIREN", "siren": "SIREN",
"accountNumber": "Numéro de compte", "accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA", "tvaMode": "Mode de TVA",
@@ -193,7 +190,6 @@
"paymentDelay": "Délai de règlement", "paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement", "paymentType": "Type de règlement",
"bank": "Banque", "bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé", "ribLabel": "Libellé",
"ribBic": "BIC", "ribBic": "BIC",
"ribIban": "IBAN", "ribIban": "IBAN",
@@ -272,7 +268,7 @@
}, },
"edit": { "edit": {
"title": "Modifier le client", "title": "Modifier le client",
"back": "Retour à la consultation", "back": "Retour au répertoire",
"loading": "Chargement du client…", "loading": "Chargement du client…",
"notFound": "Client introuvable.", "notFound": "Client introuvable.",
"save": "Enregistrer" "save": "Enregistrer"
@@ -354,7 +350,6 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"accounting": { "accounting": {
"infoTitle": "Informations",
"siren": "SIREN", "siren": "SIREN",
"accountNumber": "Numéro de compte", "accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA", "tvaMode": "Mode de TVA",
@@ -425,7 +420,7 @@
}, },
"edit": { "edit": {
"title": "Modifier le prestataire", "title": "Modifier le prestataire",
"back": "Retour à la consultation", "back": "Retour à la fiche",
"loading": "Chargement…", "loading": "Chargement…",
"notFound": "Prestataire introuvable.", "notFound": "Prestataire introuvable.",
"save": "Enregistrer" "save": "Enregistrer"
@@ -446,7 +441,6 @@
"categoryRequired": "Sélectionnez au moins une catégorie." "categoryRequired": "Sélectionnez au moins une catégorie."
}, },
"contact": { "contact": {
"title": "Contact {n}",
"lastName": "Nom", "lastName": "Nom",
"firstName": "Prénom", "firstName": "Prénom",
"jobTitle": "Fonction", "jobTitle": "Fonction",
@@ -458,8 +452,8 @@
"add": "Nouveau contact" "add": "Nouveau contact"
}, },
"address": { "address": {
"title": "Adresse {n}",
"sites": "Sites", "sites": "Sites",
"categories": "Catégorie",
"contacts": "Contact(s) rattaché(s)", "contacts": "Contact(s) rattaché(s)",
"country": "Pays", "country": "Pays",
"postalCode": "Code postal", "postalCode": "Code postal",
@@ -472,7 +466,6 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"accounting": { "accounting": {
"infoTitle": "Informations",
"siren": "SIREN", "siren": "SIREN",
"accountNumber": "Numéro de compte", "accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA", "tvaMode": "Mode de TVA",
@@ -480,7 +473,6 @@
"paymentDelay": "Délai de règlement", "paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement", "paymentType": "Type de règlement",
"bank": "Banque", "bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé", "ribLabel": "Libellé",
"ribBic": "BIC", "ribBic": "BIC",
"ribIban": "IBAN", "ribIban": "IBAN",
@@ -517,7 +509,7 @@
"name": "Nom", "name": "Nom",
"certification": "Certification", "certification": "Certification",
"validityDate": "Date de validité", "validityDate": "Date de validité",
"lastActivity": "Dernière modification" "lastActivity": "Dernière activité"
}, },
"certification": { "certification": {
"QUALIMAT": "QUALIMAT", "QUALIMAT": "QUALIMAT",
@@ -566,8 +558,8 @@
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?" "message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
}, },
"price": { "price": {
"group": "Transport", "group": "Type de transport",
"carrier": "Fournisseurs / Clients", "carrier": "Transporteurs",
"aproOrSite": "Adresse sites", "aproOrSite": "Adresse sites",
"delivery": "Adresse livraisons", "delivery": "Adresse livraisons",
"forfait": "Forfait (€)", "forfait": "Forfait (€)",
@@ -637,7 +629,6 @@
"uploadFailed": "Le téléversement de la décharge a échoué." "uploadFailed": "Le téléversement de la décharge a échoué."
}, },
"address": { "address": {
"title": "Adresse",
"country": "Pays", "country": "Pays",
"postalCode": "Code postal", "postalCode": "Code postal",
"city": "Ville", "city": "Ville",
@@ -647,7 +638,6 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"contact": { "contact": {
"title": "Contact {n}",
"lastName": "Nom", "lastName": "Nom",
"firstName": "Prénom", "firstName": "Prénom",
"jobTitle": "Fonction", "jobTitle": "Fonction",
@@ -665,7 +655,6 @@
"confirm": "Supprimer" "confirm": "Supprimer"
}, },
"price": { "price": {
"title": "Prix {n}",
"direction": "Sens", "direction": "Sens",
"directionClient": "Client", "directionClient": "Client",
"directionSupplier": "Fournisseur", "directionSupplier": "Fournisseur",
@@ -703,75 +692,6 @@
} }
} }
}, },
"logistique": {
"weighingTickets": {
"title": "Tickets de pesée",
"add": "Ajouter",
"export": "Exporter",
"empty": "Aucun ticket de pesée pour l'instant.",
"column": {
"number": "Numéro",
"client": "Client",
"supplier": "Fournisseur",
"other": "Autre",
"date": "Date",
"weight": "Poids",
"status": "Statut"
},
"status": {
"draft": "En attente",
"validated": "Terminée"
},
"form": {
"back": "Retour à la liste",
"addTitle": "Ajouter un ticket de pesée",
"emptyBlock": "Poids à vide",
"fullBlock": "Poids à plein",
"date": "Date",
"weight": "Poids (Kg)",
"dsd": "DSD",
"immatriculation": "Immatriculation",
"plateFreeFormat": "Tout format",
"save": "Enregistrer",
"validate": "Valider",
"print": "Imprimer",
"weightRequired": "Le poids est obligatoire : effectuez une pesée.",
"dsdRequired": "Le DSD est obligatoire : effectuez une pesée.",
"counterparty": {
"type": "Fournisseur / Client / Autre",
"supplier": "Fournisseur",
"client": "Client",
"other": "Autre"
},
"weighbridge": {
"auto": "Pesée bascule",
"manual": "Pesée manuelle",
"confirmTitle": "Pesée bascule",
"confirmMessage": "Êtes-vous sûr de vouloir déclencher une pesée ?",
"validate": "Valider",
"unavailable": "Pont bascule indisponible — passez en pesée manuelle."
},
"manual": {
"title": "Pesée manuelle",
"weight": "Poids (Kg)",
"dsd": "DSD",
"save": "Enregistrer",
"weightRequired": "Le poids est obligatoire.",
"dsdRequired": "Le DSD est obligatoire."
}
},
"edit": {
"title": "Ticket de pesée {number}",
"titleFallback": "Modifier un ticket de pesée",
"loading": "Chargement du ticket…",
"notFound": "Ticket de pesée introuvable."
},
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export des tickets de pesée a échoué. Réessayez."
}
}
},
"auth": { "auth": {
"login": "Connexion", "login": "Connexion",
"logout": "Deconnexion", "logout": "Deconnexion",
@@ -819,8 +739,6 @@
"core_permission": "Permission", "core_permission": "Permission",
"sites_site": "Site", "sites_site": "Site",
"catalog_category": "Catégorie", "catalog_category": "Catégorie",
"catalog_product": "Produit",
"catalog_storage": "Stockage",
"commercial_client": "Client", "commercial_client": "Client",
"commercial_clientaddress": "Adresse client", "commercial_clientaddress": "Adresse client",
"commercial_clientcontact": "Contact client", "commercial_clientcontact": "Contact client",
@@ -872,8 +790,7 @@
"auth": { "auth": {
"logout": "Deconnexion reussie" "logout": "Deconnexion reussie"
}, },
"title": "Succès", "title": "Succès"
"deleted": "Suppression effectuée"
}, },
"admin": { "admin": {
"roles": { "roles": {
@@ -1023,74 +940,6 @@
"duplicate": "Une catégorie nommée « {name} » existe déjà.", "duplicate": "Une catégorie nommée « {name} » existe déjà.",
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez." "typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
} }
},
"products": {
"title": "Catalogue produit",
"add": "Ajouter",
"export": "Exporter",
"empty": "Aucun produit pour l'instant.",
"column": {
"name": "Nom",
"code": "Numéro",
"category": "Catégorie"
},
"state": {
"PURCHASE": "Achat",
"SALE": "Vendu",
"OTHER": "Autre"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"category": "Catégorie",
"categoryAll": "Toutes les catégories",
"state": "État",
"stateAll": "Tous les états",
"site": "Sites",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
"form": {
"title": "Ajouter un produit",
"back": "Retour au catalogue",
"submit": "Valider",
"states": "État du produit",
"sites": "Site",
"name": "Nom du produit",
"code": "Code produit",
"category": "Catégorie produit",
"storageTypes": "Type de stockage",
"manufactured": "Fabriqué",
"containsMolasses": "Contient de la mélasse",
"duplicateCode": "Un produit portant ce code existe déjà."
},
"edit": {
"title": "Modifier le produit",
"back": "Retour",
"save": "Enregistrer",
"loading": "Chargement du produit…",
"notFound": "Produit introuvable."
},
"consultation": {
"title": "Fiche produit",
"back": "Retour au catalogue",
"loading": "Chargement du produit…",
"notFound": "Produit introuvable."
},
"action": {
"edit": "Modifier"
},
"tab": {
"suppliers": "Fournisseurs",
"clients": "Clients",
"placeholder": "Cet onglet est en cours de développement"
},
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du catalogue produit a échoué. Réessayez.",
"createSuccess": "Produit créé avec succès",
"updateSuccess": "Produit mis à jour avec succès"
}
} }
} }
} }
@@ -1,6 +1,5 @@
<template> <template>
<MalioModal <MalioModal
:dismissable="false"
:model-value="modelValue" :model-value="modelValue"
modal-class="max-w-md" modal-class="max-w-md"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
@@ -30,7 +30,6 @@
<MalioSelectCheckbox <MalioSelectCheckbox
v-model="form.categoryTypeIds.value" v-model="form.categoryTypeIds.value"
:options="typeOptions" :options="typeOptions"
:max-tags="3"
:label="t('admin.categories.form.types')" :label="t('admin.categories.form.types')"
:error="form.errors.categoryTypes" :error="form.errors.categoryTypes"
:display-tag="true" :display-tag="true"
@@ -1,54 +0,0 @@
<template>
<!--
Onglets « Fournisseurs » / « Clients » de la fiche produit HORS PERIMETRE
V0 (HP-M6-01, RG-6.10) : ils dependent d'un module Contrat inexistant.
Rendu en placeholder « en cours de développement » (meme composant que les
onglets non-dev des fiches M1→M4). AUCUN appel API, AUCUN champ saisissable.
Visibilite conditionnee par l'etat du produit (cf. spec C3, « Aucun » = OTHER) :
- « Fournisseurs » : visible si l'etat contient Achat (PURCHASE) ou Aucun (OTHER) ;
- « Clients » : visible si l'etat contient Vendu (SALE) ou Aucun (OTHER).
Si aucun onglet n'est applicable (etat vide), rien n'est rendu.
-->
<MalioTabList v-if="tabs.length" v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #suppliers><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
<template #clients><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
</MalioTabList>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = defineProps<{
/** Etats du produit (codes enum PURCHASE / SALE / OTHER) pilotant la visibilite. */
states: string[]
}>()
const { t } = useI18n()
// RG (spec C3) : « Fournisseurs » si Achat ou Aucun ; « Clients » si Vendu ou Aucun.
const showSuppliers = computed(() => props.states.includes('PURCHASE') || props.states.includes('OTHER'))
const showClients = computed(() => props.states.includes('SALE') || props.states.includes('OTHER'))
// Icone (Iconify) par onglet, alignee sur la convention des fiches existantes.
const tabs = computed(() => {
const list: { key: string, label: string, icon: string }[] = []
if (showSuppliers.value) {
list.push({ key: 'suppliers', label: t('admin.products.tab.suppliers'), icon: 'mdi:truck-outline' })
}
if (showClients.value) {
list.push({ key: 'clients', label: t('admin.products.tab.clients'), icon: 'mdi:account-group-outline' })
}
return list
})
const activeTab = ref('suppliers')
// Si l'onglet actif disparait suite a un changement d'etat, retombe sur le premier
// onglet encore disponible (evite un onglet actif fantome).
watch(tabs, (list) => {
if (list.length && !list.some(tab => tab.key === activeTab.value)) {
activeTab.value = list[0].key
}
}, { immediate: true })
</script>
@@ -1,64 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, nextTick } from 'vue'
import ProductPlaceholderTabs from '../ProductPlaceholderTabs.vue'
// i18n auto-import : retourne la cle telle quelle.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
// Stub de MalioTabList : expose les `key` des onglets recus (data-tab) pour
// verifier la visibilite conditionnee par l'etat, sans dependre de la lib UI.
const TabListStub = defineComponent({
props: { tabs: { type: Array, default: () => [] }, modelValue: { type: String, default: '' } },
setup(props) {
return () => h(
'div',
{ 'data-testid': 'tablist' },
(props.tabs as { key: string }[]).map(tab => h('span', { 'data-tab': tab.key })),
)
},
})
const PlaceholderStub = defineComponent({ setup() { return () => h('div') } })
function mountTabs(states: string[]) {
return mount(ProductPlaceholderTabs, {
props: { states },
global: { stubs: { MalioTabList: TabListStub, ComingSoonPlaceholder: PlaceholderStub } },
})
}
const tabKeys = (wrapper: ReturnType<typeof mountTabs>): string[] =>
wrapper.findAll('[data-tab]').map(node => node.attributes('data-tab') ?? '')
describe('ProductPlaceholderTabs — visibilite conditionnee par l\'etat', () => {
it('Achat (PURCHASE) : affiche uniquement « Fournisseurs »', () => {
expect(tabKeys(mountTabs(['PURCHASE']))).toEqual(['suppliers'])
})
it('Vendu (SALE) : affiche uniquement « Clients »', () => {
expect(tabKeys(mountTabs(['SALE']))).toEqual(['clients'])
})
it('Aucun (OTHER) : affiche les deux onglets', () => {
expect(tabKeys(mountTabs(['OTHER']))).toEqual(['suppliers', 'clients'])
})
it('Achat + Vendu : affiche les deux onglets', () => {
expect(tabKeys(mountTabs(['PURCHASE', 'SALE']))).toEqual(['suppliers', 'clients'])
})
it('etat vide : ne rend aucun onglet (MalioTabList absent)', () => {
const wrapper = mountTabs([])
expect(wrapper.find('[data-testid="tablist"]').exists()).toBe(false)
})
it('retombe sur le premier onglet visible si l\'actif disparait', async () => {
// OTHER -> suppliers actif par defaut ; passage a SALE retire « Fournisseurs ».
const wrapper = mountTabs(['OTHER'])
await wrapper.setProps({ states: ['SALE'] })
await nextTick()
// Seul « Clients » subsiste : pas d'onglet actif fantome (verifie via le modelValue).
const tablist = wrapper.findComponent(TabListStub)
expect(tablist.props('modelValue')).toBe('clients')
})
})
@@ -1,293 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { nextTick } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useProductForm } from '../useProductForm'
// Stubs des auto-imports Nuxt consommes par le composable + ses dependances.
const mockGet = vi.hoisted(() => vi.fn())
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = 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: vi.fn(),
}))
vi.stubGlobal('useToast', () => ({
success: mockToastSuccess,
error: mockToastError,
}))
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
// (elle consomme useToast/useI18n deja stubbes) pour tester l'integration 422/409.
vi.stubGlobal('useFormErrors', useFormErrors)
vi.stubGlobal('useI18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}::${JSON.stringify(params)}` : key,
}))
/** Referentiel PLAT des types de stockage (renvoye tel quel, sans filtre site). */
const STORAGE_TYPES = {
member: [
{ '@id': '/api/storage_types/9', label: 'Tas' },
{ '@id': '/api/storage_types/5', label: 'Cellule' },
{ '@id': '/api/storage_types/7', label: 'Cuve mélasse' },
],
}
describe('useProductForm', () => {
beforeEach(() => {
mockGet.mockReset()
mockPost.mockReset()
mockPatch.mockReset()
mockToastSuccess.mockReset()
mockToastError.mockReset()
// Routage des GET par url (referentiels). Le stockage est un referentiel
// plat : meme reponse quelle que soit la requete.
mockGet.mockImplementation((url: string) => {
if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
}
if (url === '/categories') {
return Promise.resolve({ member: [{ '@id': '/api/categories/12', name: 'Céréales' }] })
}
if (url === '/storage_types') {
return Promise.resolve(STORAGE_TYPES)
}
return Promise.resolve({ member: [] })
})
})
describe('RG-6.03 — champs conditionnels « Vendu »', () => {
it('isSale est vrai uniquement si states contient SALE', () => {
const { form, isSale } = useProductForm()
expect(isSale.value).toBe(false)
form.states = ['PURCHASE']
expect(isSale.value).toBe(false)
form.states = ['PURCHASE', 'SALE']
expect(isSale.value).toBe(true)
})
it('remet manufactured / containsMolasses a false quand SALE est retire', async () => {
const { form, isSale } = useProductForm()
form.states = ['SALE']
form.manufactured = true
form.containsMolasses = true
await nextTick()
expect(isSale.value).toBe(true)
form.states = ['PURCHASE']
await nextTick()
expect(form.manufactured).toBe(false)
expect(form.containsMolasses).toBe(false)
})
})
describe('RG-6.06 — types de stockage (referentiel plat)', () => {
it('loadReferentials charge TOUS les types de stockage, sans filtre site', async () => {
const { storageTypeOptions, loadReferentials } = useProductForm()
await loadReferentials()
const storageCall = mockGet.mock.calls.find(c => c[0] === '/storage_types')
expect(storageCall).toBeDefined()
// Aucun filtre siteId envoye (referentiel plat).
expect(storageCall?.[1]).not.toHaveProperty('siteId[]')
expect(storageTypeOptions.value.map(o => o.value)).toEqual([
'/api/storage_types/9',
'/api/storage_types/5',
'/api/storage_types/7',
])
})
it('setSites met a jour les sites sans recharger le stockage ni purger la selection', async () => {
const { form, setSites, setStorageTypes, loadReferentials } = useProductForm()
await loadReferentials()
const storageCallsBefore = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length
setStorageTypes(['/api/storage_types/9'])
setSites(['/api/sites/1'])
expect(form.siteIris).toEqual(['/api/sites/1'])
// Selection conservee : plus de cascade ni de purge par site.
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
// setSites ne declenche aucun nouvel appel /storage_types.
const storageCallsAfter = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length
expect(storageCallsAfter).toBe(storageCallsBefore)
})
})
describe('submit — POST /products', () => {
function fillValidForm(form: ReturnType<typeof useProductForm>['form']): void {
form.code = 'ble-01'
form.name = 'Blé tendre'
form.states = ['PURCHASE', 'SALE']
form.siteIris = ['/api/sites/1']
form.categoryIri = '/api/categories/12'
form.storageTypeIris = ['/api/storage_types/9']
form.manufactured = true
form.containsMolasses = false
}
it('poste le payload (relations en IRI) et retourne true au succes', async () => {
mockPost.mockResolvedValueOnce({ id: 34 })
const { form, submit } = useProductForm()
fillValidForm(form)
const ok = await submit()
expect(ok).toBe(true)
expect(mockPost).toHaveBeenCalledWith(
'/products',
{
code: 'ble-01',
name: 'Blé tendre',
states: ['PURCHASE', 'SALE'],
manufactured: true,
containsMolasses: false,
category: '/api/categories/12',
sites: ['/api/sites/1'],
storageTypes: ['/api/storage_types/9'],
},
expect.objectContaining({ toast: false }),
)
expect(mockToastSuccess).toHaveBeenCalled()
})
it('force manufactured / containsMolasses a false hors « Vendu » (RG-6.03)', async () => {
mockPost.mockResolvedValueOnce({ id: 35 })
const { form, submit } = useProductForm()
fillValidForm(form)
// L'utilisateur retire « Vendu » apres avoir coche les booleens.
form.states = ['PURCHASE']
await submit()
const payload = mockPost.mock.calls[0][1]
expect(payload.manufactured).toBe(false)
expect(payload.containsMolasses).toBe(false)
})
it('omet `category` du payload quand aucune categorie n\'est choisie', async () => {
// Envoyer category:null casserait la denormalisation back (type IRI
// attendu) et court-circuiterait les autres violations -> on l'omet.
mockPost.mockResolvedValueOnce({ id: 40 })
const { form, submit } = useProductForm()
fillValidForm(form)
form.categoryIri = null
await submit()
const payload = mockPost.mock.calls[0][1]
expect(payload).not.toHaveProperty('category')
})
it('mappe un 409 doublon de code sur errors.code + toast explicite', async () => {
mockPost.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
const { form, errors, submit } = useProductForm()
fillValidForm(form)
const ok = await submit()
expect(ok).toBe(false)
expect(errors.code).toBe('admin.products.form.duplicateCode')
expect(mockToastError).toHaveBeenCalled()
})
it('mappe une 422 inline par champ (errors.code) sans toast', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'code', message: 'Le code produit est obligatoire.' }] },
},
})
const { form, errors, submit } = useProductForm()
fillValidForm(form)
form.code = null
const ok = await submit()
expect(ok).toBe(false)
expect(errors.code).toBe('Le code produit est obligatoire.')
expect(mockToastError).not.toHaveBeenCalled()
})
})
describe('RG-6.08 — mode edition (prefill + PATCH)', () => {
// Produit charge (memes cles que la reponse reelle § 4.0.bis : @id sur les relations).
const PRODUCT = {
id: 34,
code: 'BLE-01',
name: 'Blé tendre',
states: ['PURCHASE', 'SALE'],
manufactured: true,
containsMolasses: false,
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault', code: '86', postalCode: '86100', city: 'C', color: '#000', fullAddress: 'x' }],
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
createdAt: '', updatedAt: '',
}
it('pre-remplit le formulaire depuis le produit (relations en IRI)', async () => {
const { form, prefill } = useProductForm()
await prefill(PRODUCT)
expect(form.code).toBe('BLE-01')
expect(form.name).toBe('Blé tendre')
expect(form.states).toEqual(['PURCHASE', 'SALE'])
expect(form.categoryIri).toBe('/api/categories/12')
expect(form.siteIris).toEqual(['/api/sites/1'])
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
expect(form.manufactured).toBe(true)
})
it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => {
// Le PATCH renvoie le produit normalise : submit re-prefill le form a partir
// de la reponse (l'utilisateur reste sur l'ecran, pas de redirection).
mockPatch.mockResolvedValueOnce({ ...PRODUCT, code: 'BLE-01', name: 'Blé tendre' })
const { prefill, submit } = useProductForm()
await prefill(PRODUCT)
const ok = await submit()
expect(ok).toBe(true)
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith(
'/products/34',
expect.objectContaining({ code: 'BLE-01', name: 'Blé tendre' }),
expect.objectContaining({ toast: false }),
)
expect(mockToastSuccess).toHaveBeenCalled()
})
it('re-affiche les valeurs normalisees du serveur apres un PATCH (RG-6.07, pas de redirection)', async () => {
// Le back normalise code (trim+UPPER) et name (trim) : le form doit refleter
// la reponse serveur, pas la saisie locale.
mockPatch.mockResolvedValueOnce({ ...PRODUCT, code: 'BLE-01', name: 'Blé tendre' })
const { form, prefill, submit } = useProductForm()
await prefill(PRODUCT)
form.code = 'ble-01 '
form.name = ' Blé tendre '
await submit()
expect(form.code).toBe('BLE-01')
expect(form.name).toBe('Blé tendre')
})
it('mappe un 409 doublon de code aussi en edition', async () => {
mockPatch.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
const { errors, prefill, submit } = useProductForm()
await prefill(PRODUCT)
const ok = await submit()
expect(ok).toBe(false)
expect(errors.code).toBe('admin.products.form.duplicateCode')
expect(mockToastError).toHaveBeenCalled()
})
})
})
@@ -11,9 +11,8 @@
* la recharger a chaque ouverture du drawer. * la recharger a chaque ouverture du drawer.
* *
* State singleton au niveau module : reset automatique au logout via * State singleton au niveau module : reset automatique au logout via
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), declenche par * `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
* `clearSession()` (logout volontaire `useLogout` ou intercepteur 401). * explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
* `resetCategoriesAdmin()` reste expose pour un reset manuel/tests.
*/ */
import { ref } from 'vue' import { ref } from 'vue'
import type { CategoryType } from '~/modules/catalog/types/category' import type { CategoryType } from '~/modules/catalog/types/category'
@@ -39,9 +38,10 @@ function resetCategoriesAdminState(): void {
error.value = null error.value = null
} }
// Auto-enregistrement singleton : purge le state sur clearSession() (logout // Auto-enregistrement singleton : purge le state sur 401/clearSession
// volontaire via useLogout, ou intercepteur 401) pour eviter qu'un user suivant // pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
// (connecte sur le meme onglet) voie le referentiel de l'ancien tenant. // referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
// appelle directement `resetCategoriesAdmin()` ci-dessous.
onAuthSessionCleared(resetCategoriesAdminState) onAuthSessionCleared(resetCategoriesAdminState)
export function useCategoriesAdmin() { export function useCategoriesAdmin() {
@@ -73,9 +73,9 @@ export function useCategoriesAdmin() {
} }
/** /**
* Reset explicite expose pour un reset manuel (tests, ou appel cible). * Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
* Au logout, le reset est deja garanti par `onAuthSessionCleared` * pour garantir que la prochaine session reparte sur un state propre
* (declenche par `clearSession()` dans `auth.logout()`). * meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
*/ */
function resetCategoriesAdmin(): void { function resetCategoriesAdmin(): void {
resetCategoriesAdminState() resetCategoriesAdminState()
@@ -1,41 +0,0 @@
import { ref } from 'vue'
import type { Product } from '~/modules/catalog/types/product'
/**
* Chargement d'un produit unique (ecran « Modification produit », M6 — ERP-206).
* Lit le detail via `GET /api/products/{id}` — meme structure que la ligne de
* liste (category / sites / storageTypes embarques, § 4.0.bis).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
* Hydra complet (IRI `@id` des relations, necessaires au pre-remplissage des
* selects). Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
export function useProduct(id: number | string) {
const api = useApi()
const product = ref<Product | null>(null)
const loading = ref(false)
const error = ref(false)
/** Charge le detail du produit. En cas d'echec : `error = true`, `product = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
product.value = await api.get<Product>(
`/products/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
catch {
error.value = true
product.value = null
}
finally {
loading.value = false
}
}
return { product, loading, error, load }
}
@@ -1,202 +0,0 @@
/**
* Composable du formulaire de creation produit (M6 — ERP-205).
*
* Porte l'etat du formulaire principal, les referentiels des selects, les regles
* de gestion front (champs conditionnels RG-6.03) et la soumission
* `POST /api/products` avec mapping des erreurs 422/409 inline
* (useFormErrors). Reference : ecran « Ajouter un client » / « Ajouter un
* prestataire » (formulaire principal).
*
* Etat 100 % local a l'instance.
*/
import { computed, reactive, ref, watch } from 'vue'
import {
useSiteOptions,
useCategoryOptions,
useStorageTypeOptions,
} from '~/modules/catalog/composables/useProductOptions'
import type { Product } from '~/modules/catalog/types/product'
/** Etats produit (miroir de l'enum back Product::STATE_*). */
export const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
export function useProductForm() {
const api = useApi()
const { t } = useI18n()
const toast = useToast()
const formErrors = useFormErrors()
const sites = useSiteOptions()
const categories = useCategoryOptions({ typeCode: 'PRODUIT' })
const storage = useStorageTypeOptions()
// ── Etat du formulaire ───────────────────────────────────────────────────
// Les relations sont stockees en IRI (envoyees telles quelles au POST) ;
// `states` porte les codes enum ; les booleens conditionnels RG-6.03 a part.
const form = reactive({
code: null as string | null,
name: null as string | null,
states: [] as string[],
siteIris: [] as string[],
categoryIri: null as string | null,
storageTypeIris: [] as string[],
manufactured: false,
containsMolasses: false,
})
const submitting = ref(false)
// Id du produit edite (null = creation). Pilote l'URL/methode du submit (RG-6.08 :
// « Modification » = meme formulaire/regles que « Ajouter », bouton « Enregistrer »).
const productId = ref<number | null>(null)
// RG-6.03 : « Fabriqué » / « Contient de la mélasse » saisissables uniquement
// si l'etat contient « Vendu » (SALE).
const isSale = computed(() => form.states.includes('SALE'))
// Quand l'etat ne contient plus SALE, on remet les booleens a false : le back
// les forcerait de toute facon (RG-6.03), on evite de soumettre une valeur
// fantome saisie avant de retirer « Vendu ».
watch(isSale, (sale) => {
if (!sale) {
form.manufactured = false
form.containsMolasses = false
}
})
/** Met a jour les etats (multi-select). */
function setStates(states: string[]): void {
form.states = states
}
/** Met a jour la categorie (select simple). */
function setCategory(iri: string | null): void {
form.categoryIri = iri
}
/** Met a jour les types de stockage (multi-select). */
function setStorageTypes(iris: string[]): void {
form.storageTypeIris = iris
}
/** Met a jour les sites de disponibilite (multi-select, RG-6.04). */
function setSites(iris: string[]): void {
form.siteIris = iris
}
/**
* Charge les referentiels initiaux (sites + categories + types de stockage).
* Resilient. Les types de stockage forment un referentiel plat : on les charge
* tous d'emblee (plus de cascade par site, RG-6.06 revue).
*/
async function loadReferentials(): Promise<void> {
await Promise.allSettled([sites.load(), categories.load(), storage.load()])
}
/**
* Pre-remplit le formulaire depuis un produit charge (mode edition, RG-6.08).
* Les relations sont reprises via leur IRI `@id` (= valeur d'option des selects).
* Les options de Type de stockage sont chargees par loadReferentials (referentiel
* plat) : prefill se contente de mapper la selection.
*/
async function prefill(product: Product): Promise<void> {
productId.value = product.id
form.code = product.code
form.name = product.name
form.states = [...product.states]
form.categoryIri = product.category?.['@id'] ?? null
form.siteIris = product.sites.map(s => s['@id'])
form.manufactured = product.manufactured
form.containsMolasses = product.containsMolasses
form.storageTypeIris = product.storageTypes.map(st => st['@id'])
}
/**
* Soumet le formulaire. Retourne true au succes (la page redirige), false sinon.
* Creation → `POST /products` ; edition (productId non nul, RG-6.08) →
* `PATCH /products/{id}` (mode merge-patch gere par useApi). 422 → mapping
* inline par champ (useFormErrors) ; 409 doublon de code → erreur inline sur
* `code` + toast explicite (RG-6.01, unicite re-validee aussi en edition).
*/
async function submit(): Promise<boolean> {
if (submitting.value) {
return false
}
submitting.value = true
formErrors.clearErrors()
const editing = productId.value !== null
try {
const payload: Record<string, unknown> = {
// Chaine vide (jamais null) : les setters back setCode/setName attendent
// un `string` non-nullable -> envoyer null leverait une erreur de type
// (denormalisation) qui court-circuiterait toutes les autres violations.
// Avec '', la contrainte NotBlank renvoie un message propre par champ.
code: form.code ?? '',
name: form.name ?? '',
states: form.states,
// RG-6.03 : booleens forces a false hors « Vendu » (le back les
// re-force, on garde le payload coherent).
manufactured: isSale.value ? form.manufactured : false,
containsMolasses: isSale.value ? form.containsMolasses : false,
sites: form.siteIris,
storageTypes: form.storageTypeIris,
}
// `category` attend un IRI (string) : envoyer null declencherait une
// erreur de denormalisation API Platform qui court-circuiterait TOUTES
// les autres violations. On omet la cle quand aucune categorie n'est
// choisie -> la contrainte NotNull renvoie un message propre, et les
// autres champs sont valides dans la meme 422 (mapping inline ERP-101).
if (form.categoryIri) {
payload.category = form.categoryIri
}
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
if (editing) {
const updated = await api.patch<Product>(`/products/${productId.value}`, payload, options)
toast.success({ title: t('admin.products.toast.updateSuccess') })
// L'utilisateur garde la main (pas de redirection, calque client/
// fournisseur) : on reaffiche les valeurs normalisees renvoyees par le
// serveur (code trim+UPPER, name trim — RG-6.07) directement dans le form.
await prefill(updated)
}
else {
await api.post('/products', payload, options)
toast.success({ title: t('admin.products.toast.createSuccess') })
}
return true
}
catch (error) {
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
// Doublon de code (RG-6.01) : inline sur le champ + toast explicite.
const message = t('admin.products.form.duplicateCode')
formErrors.setError('code', message)
toast.error({ title: t('admin.products.toast.error'), message })
}
else {
formErrors.handleApiError(error, { fallbackMessage: t('admin.products.toast.error') })
}
return false
}
finally {
submitting.value = false
}
}
return {
form,
productId,
errors: formErrors.errors,
submitting,
isSale,
siteOptions: sites.options,
categoryOptions: categories.options,
storageTypeOptions: storage.options,
setStates,
setCategory,
setStorageTypes,
setSites,
loadReferentials,
prefill,
submit,
}
}
@@ -1,101 +0,0 @@
/**
* Composables d'options des selects du formulaire produit (M6 — ERP-205).
*
* Chaque referentiel est borne (quelques dizaines d'entrees) : on le recupere en
* entier via l'echappatoire `?pagination=false`, avec l'en-tete
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
* quelle dans le payload POST (relations ManyToOne / ManyToMany).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Chaque appel cree
* sa propre instance ; le formulaire en consomme une via `useProductForm`.
*/
import { ref } from 'vue'
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
// Couleur de fond optionnelle de l'option (hex #RRGGBB). Alimentee par le
// referentiel sites (couleur d'identification du site, affichee sur les tags
// selectionnes du multiselect).
color?: string
// Couleur de texte optionnelle (hex). Sites : blanc, pour rester lisible
// sur le fond colore du tag.
textColor?: string
}
/** Membre Hydra minimal commun aux referentiels consommes ici. */
interface HydraMember {
'@id': string
name?: string
label?: string
color?: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
/**
* Recupere une collection complete (pagination desactivee) et la projette en
* options `{ value: IRI, label }`. Resilient : l'appelant gere l'echec (liste vide).
*/
async function fetchOptions(
url: string,
query: Record<string, string | string[]>,
toLabel: (member: HydraMember) => string,
toColor?: (member: HydraMember) => string | undefined,
): Promise<RefOption[]> {
const res = await useApi().get<{ member?: HydraMember[] }>(
url,
{ pagination: 'false', ...query },
{ headers: LD_JSON_HEADERS, toast: false },
)
return (res.member ?? []).map(m => ({
value: m['@id'],
label: toLabel(m),
// Couleur reportee uniquement si un extracteur est fourni (ex: sites).
...(toColor ? { color: toColor(m) } : {}),
}))
}
/** Sites de disponibilite (libelle = nom du site). */
export function useSiteOptions() {
const options = ref<RefOption[]>([])
async function load(): Promise<void> {
// Sites : couleur de fond depuis l'embed + texte blanc pour rester lisible.
const sites = await fetchOptions('/sites', {}, s => s.name ?? '', s => s.color)
options.value = sites.map(o => ({ ...o, textColor: '#FFFFFF' }))
}
return { options, load }
}
/**
* Categories produit. Filtrees au type voulu (`?typeCode=PRODUIT` pour le produit,
* RG-6.05) cote serveur — le provider Category supporte deja `typeCode`.
*/
export function useCategoryOptions(params: { typeCode: string }) {
const options = ref<RefOption[]>([])
async function load(): Promise<void> {
options.value = await fetchOptions('/categories', { typeCode: params.typeCode }, c => c.name ?? '')
}
return { options, load }
}
/**
* Types de stockage (libelle = `label`). Referentiel PLAT : on charge TOUS les
* types, sans filtrage par site (RG-6.06 revue — la dispo par site releve du futur
* module Stockage).
*/
export function useStorageTypeOptions() {
const options = ref<RefOption[]>([])
async function load(): Promise<void> {
options.value = await fetchOptions('/storage_types', {}, s => s.label ?? '')
}
return { options, load }
}
@@ -1,139 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, Suspense } from 'vue'
// Produit charge simule (cles de la reponse reelle § 4.0.bis).
const PRODUCT = {
id: 34,
code: 'BLE-01',
name: 'Blé tendre',
states: ['PURCHASE', 'SALE'],
manufactured: true,
containsMolasses: false,
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }],
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
createdAt: '', updatedAt: '',
}
const fx = vi.hoisted(() => ({ load: vi.fn() }))
vi.mock('~/modules/catalog/composables/useProduct', async () => {
const { ref } = await import('vue')
return {
useProduct: () => ({
product: ref(PRODUCT),
loading: ref(false),
error: ref(false),
load: fx.load,
}),
}
})
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
const mockPush = vi.hoisted(() => vi.fn())
const mockNavigateTo = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useRoute', () => ({ params: { id: '34' } }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
vi.stubGlobal('navigateTo', mockNavigateTo)
const ViewPage = (await import('../admin/products/[id]/index.vue')).default
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
// Input lecture seule : expose le label + la valeur affichee (model-value).
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) { return () => h('input', { 'data-label': props.label, 'data-value': String(props.modelValue ?? '') }) },
})
const CheckboxStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
})
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioCheckbox: CheckboxStub,
ProductPlaceholderTabs: TabsStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { ViewPage },
setup: () => () => h(Suspense, null, { default: () => h(ViewPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Consultation produit (page /admin/products/{id})', () => {
beforeEach(() => {
fx.load.mockReset().mockResolvedValue(undefined)
mockPush.mockReset()
mockNavigateTo.mockReset()
mockCan.mockReset().mockReturnValue(true)
})
it('charge le produit au montage', async () => {
await mountPage()
expect(fx.load).toHaveBeenCalled()
})
it('redirige vers la liste sans la permission view', async () => {
mockCan.mockReturnValue(false)
await mountPage()
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
})
it('affiche les champs en lecture seule (libelles mappes)', async () => {
const wrapper = await mountPage()
const valueOf = (label: string) =>
wrapper.find(`[data-label="${label}"]`).attributes('data-value')
expect(valueOf('admin.products.form.name')).toBe('Blé tendre')
expect(valueOf('admin.products.form.code')).toBe('BLE-01')
expect(valueOf('admin.products.form.category')).toBe('Céréales')
expect(valueOf('admin.products.form.sites')).toBe('Chatellerault')
expect(valueOf('admin.products.form.storageTypes')).toBe('Tas')
// Etats : libelles i18n joints.
expect(valueOf('admin.products.form.states')).toBe('admin.products.state.PURCHASE, admin.products.state.SALE')
})
it('bouton « Modifier » (manage) → ecran d\'edition', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="admin.products.action.edit"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/admin/products/34/edit')
})
it('masque « Modifier » sans la permission manage', async () => {
// view OK mais manage refuse.
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
const wrapper = await mountPage()
expect(wrapper.find('[data-label="admin.products.action.edit"]').exists()).toBe(false)
})
it('n\'affiche AUCUN onglet en consultation (coquilles vides masquees, ERP-193)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(false)
})
it('masque un champ vide / une checkbox non cochee (ERP-193, isFilled)', async () => {
const wrapper = await mountPage()
// containsMolasses = false dans le fixture => case masquee.
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
// manufactured = true => case affichee.
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
})
})
@@ -1,155 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, Suspense } from 'vue'
// Produit charge simule (cles de la reponse reelle § 4.0.bis).
const PRODUCT = {
id: 34,
code: 'BLE-01',
name: 'Blé tendre',
states: ['PURCHASE'],
manufactured: false,
containsMolasses: false,
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }],
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
createdAt: '', updatedAt: '',
}
// Holders crees dans les factories (vue initialise au moment de l'import page).
const fx = vi.hoisted(() => ({
isSale: null as unknown as { value: boolean },
submit: vi.fn(),
prefill: vi.fn(),
loadReferentials: vi.fn(),
load: vi.fn(),
}))
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
const { ref, reactive } = await import('vue')
fx.isSale = ref(false)
return {
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
useProductForm: () => ({
form: reactive({
code: null, name: null, states: [], siteIris: [],
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
}),
errors: reactive({}),
submitting: ref(false),
isSale: fx.isSale,
siteOptions: ref([]),
categoryOptions: ref([]),
storageTypeOptions: ref([]),
setStates: vi.fn(),
setCategory: vi.fn(),
setStorageTypes: vi.fn(),
setSites: vi.fn(),
loadReferentials: fx.loadReferentials,
prefill: fx.prefill,
submit: fx.submit,
}),
}
})
vi.mock('~/modules/catalog/composables/useProduct', async () => {
const { ref } = await import('vue')
return {
useProduct: () => ({
product: ref(PRODUCT),
loading: ref(false),
error: ref(false),
load: fx.load,
}),
}
})
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
const mockPush = vi.hoisted(() => vi.fn())
const mockNavigateTo = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useRoute', () => ({ params: { id: '34' } }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
vi.stubGlobal('navigateTo', mockNavigateTo)
const EditPage = (await import('../admin/products/[id]/edit.vue')).default
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) { return () => h('input', { 'data-label': props.label }) },
})
const CheckboxStub = defineComponent({
props: { label: { type: String, default: '' } },
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
})
// Placeholder : rendu sans aucun appel API (juste un marqueur).
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioSelect: InputStub,
MalioSelectCheckbox: InputStub,
MalioCheckbox: CheckboxStub,
ProductPlaceholderTabs: TabsStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { EditPage },
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Modifier un produit (page /admin/products/{id}/edit)', () => {
beforeEach(() => {
fx.submit.mockReset().mockResolvedValue(true)
fx.prefill.mockReset().mockResolvedValue(undefined)
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
fx.load.mockReset().mockResolvedValue(undefined)
mockPush.mockReset()
mockNavigateTo.mockReset()
mockCan.mockReset().mockReturnValue(true)
fx.isSale.value = false
})
it('charge le produit et pre-remplit le formulaire au montage', async () => {
await mountPage()
expect(fx.load).toHaveBeenCalled()
expect(fx.prefill).toHaveBeenCalledWith(PRODUCT)
})
it('redirige vers la consultation sans la permission manage', async () => {
mockCan.mockReturnValue(false)
await mountPage()
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products/34')
})
it('bouton « Enregistrer » : submit (PATCH) SANS redirection (l\'utilisateur garde la main)', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="admin.products.edit.save"]').trigger('click')
await flushPromises()
expect(fx.submit).toHaveBeenCalled()
// On reste sur l'ecran d'edition : aucune navigation au succes (calque client/fournisseur).
expect(mockPush).not.toHaveBeenCalled()
})
it('affiche les onglets placeholder (rendu sans appel API)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(true)
})
})
@@ -1,147 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, Suspense } from 'vue'
// ── Mock du composable form (sa logique est testee a part : useProductForm.spec).
// Ici on teste le WIRING de la page : rendu conditionnel RG-6.03 + submit→redirect.
// Les refs partagees sont creees DANS la factory (vue est initialise au moment ou
// la page est importee) et exposees via un holder hoiste pour pilotage par test.
const fx = vi.hoisted(() => ({
isSale: null as unknown as { value: boolean },
submit: vi.fn(),
loadReferentials: vi.fn(),
}))
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
const { ref, reactive } = await import('vue')
fx.isSale = ref(false)
return {
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
useProductForm: () => ({
form: reactive({
code: null, name: null, states: [], siteIris: [],
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
}),
errors: reactive({}),
submitting: ref(false),
isSale: fx.isSale,
siteOptions: ref([]),
categoryOptions: ref([]),
storageTypeOptions: ref([]),
setStates: vi.fn(),
setCategory: vi.fn(),
setStorageTypes: vi.fn(),
setSites: vi.fn(),
loadReferentials: fx.loadReferentials,
submit: fx.submit,
}),
}
})
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
const mockPush = vi.hoisted(() => vi.fn())
const mockNavigateTo = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
vi.stubGlobal('navigateTo', mockNavigateTo)
const NewPage = (await import('../admin/products/new.vue')).default
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) { return () => h('input', { 'data-label': props.label }) },
})
const CheckboxStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
})
// Placeholder (onglets Fournisseurs/Clients) : marqueur sans appel API.
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioSelect: InputStub,
MalioSelectCheckbox: InputStub,
MalioCheckbox: CheckboxStub,
ProductPlaceholderTabs: TabsStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { NewPage },
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Ajouter un produit (page /admin/products/new)', () => {
beforeEach(() => {
fx.submit.mockReset().mockResolvedValue(true)
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
mockPush.mockReset()
mockNavigateTo.mockReset()
mockCan.mockReset().mockReturnValue(true)
fx.isSale.value = false
})
it('redirige vers la liste sans la permission manage', async () => {
mockCan.mockReturnValue(false)
await mountPage()
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
})
it('charge les referentiels au montage', async () => {
await mountPage()
expect(fx.loadReferentials).toHaveBeenCalled()
})
it('RG-6.03 : masque Fabriqué / mélasse tant que l\'etat ne contient pas « Vendu »', async () => {
fx.isSale.value = false
const wrapper = await mountPage()
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(false)
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
})
it('RG-6.03 : affiche Fabriqué / mélasse quand l\'etat contient « Vendu »', async () => {
fx.isSale.value = true
const wrapper = await mountPage()
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(true)
})
it('« Valider » : submit puis retour a la liste au succes', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
await flushPromises()
expect(fx.submit).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/admin/products')
})
it('ne redirige pas si submit echoue (erreurs inline)', async () => {
fx.submit.mockResolvedValueOnce(false)
const wrapper = await mountPage()
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
await flushPromises()
expect(mockPush).not.toHaveBeenCalled()
})
it('n\'affiche PAS les onglets Fournisseurs/Clients a l\'ajout (avant validation)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(false)
})
})
@@ -1,272 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref } from 'vue'
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
// La page ne les importe pas (auto-import) : on les expose en globals pour le
// runtime de test (happy-dom). Meme philosophie que les specs M1→M5.
const mockPush = vi.hoisted(() => vi.fn())
const mockApiGet = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
const mockSetFilters = vi.hoisted(() => vi.fn())
const mockFetch = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
// usePaginatedList est l'auto-import pilotant la liste : on controle items +
// setFilters + fetch. La ligne reproduit le contrat JSON reel (§ 4.0.bis).
vi.stubGlobal('usePaginatedList', () => ({
items: ref<Array<Record<string, unknown>>>([
{
id: 34,
code: 'BLE-TENDRE-01',
name: 'Blé tendre',
states: ['PURCHASE', 'SALE'],
manufactured: true,
containsMolasses: true,
category: { id: 12, name: 'Céréales', code: 'CEREALES' },
sites: [{ id: 1, name: 'Chatellerault', code: '86' }],
storageTypes: [{ id: 9, code: 'TAS', label: 'Tas' }],
},
]),
totalItems: ref(1),
currentPage: ref(1),
itemsPerPage: ref(10),
itemsPerPageOptions: ref([10, 25, 50]),
fetch: mockFetch,
goToPage: vi.fn(),
setItemsPerPage: vi.fn(),
setFilters: mockSetFilters,
}))
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
globalThis.URL.revokeObjectURL = vi.fn()
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
const ProductsIndex = (await import('../admin/products/index.vue')).default
// ── Stubs de composants ──────────────────────────────────────────────────────
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const DataTableStub = defineComponent({
props: { items: { type: Array, default: () => [] } },
emits: ['row-click', 'update:page', 'update:per-page'],
setup(props, { emit }) {
return () => h('div', { 'data-testid': 'datatable' },
(props.items as Array<Record<string, unknown>>).map(it =>
h('tr', {
'data-row-id': it.id,
'data-name': it.name,
'data-code': it.code,
'data-category': it.categoryName,
'onClick': () => emit('row-click', it),
}),
),
)
},
})
const DrawerStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) {
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
},
})
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
const PageHeaderStub = defineComponent({
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
})
const CheckboxStub = defineComponent({
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
emits: ['update:model-value'],
setup(props, { emit }) {
return () => h('input', {
'type': 'checkbox',
'data-id': props.id,
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
})
},
})
const SelectStub = defineComponent({
props: {
modelValue: { type: [String, Number, null] as unknown as () => string | number | null, default: null },
options: { type: Array, default: () => [] },
emptyOptionLabel: { type: String, default: '' },
},
emits: ['update:model-value'],
setup(props, { emit }) {
return () => h('select', {
'data-empty-label': props.emptyOptionLabel,
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLSelectElement).value),
}, (props.options as Array<{ value: string | number, label: string }>).map(o =>
h('option', { value: o.value }, o.label),
))
},
})
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
function mountPage() {
return mount(ProductsIndex, {
global: {
stubs: {
PageHeader: PageHeaderStub,
MalioButton: ButtonStub,
MalioDataTable: DataTableStub,
MalioDrawer: DrawerStub,
MalioAccordion: SlotStub,
MalioAccordionItem: SlotStub,
MalioInputText: InputTextStub,
MalioSelect: SelectStub,
MalioCheckbox: CheckboxStub,
},
},
})
}
describe('Catalogue produit (page /admin/products)', () => {
beforeEach(() => {
mockPush.mockReset()
mockApiGet.mockReset().mockImplementation((url: string) => {
if (url === '/categories') {
return Promise.resolve({ member: [{ '@id': '/api/categories/12', id: 12, name: 'Céréales' }] })
}
if (url === '/sites') {
return Promise.resolve({ member: [{ id: 1, name: 'Chatellerault' }] })
}
return Promise.resolve({ member: [] })
})
mockCan.mockReset().mockReturnValue(true)
mockSetFilters.mockReset()
mockFetch.mockReset()
mockToastError.mockReset()
})
it('charge la liste au montage', async () => {
mountPage()
await flushPromises()
expect(mockFetch).toHaveBeenCalled()
})
it('mappe les colonnes Nom / Numéro / Catégorie sur le JSON réel (§ 4.0.bis)', async () => {
const wrapper = mountPage()
await flushPromises()
const row = wrapper.find('tr[data-row-id="34"]')
expect(row.attributes('data-name')).toBe('Blé tendre')
expect(row.attributes('data-code')).toBe('BLE-TENDRE-01')
expect(row.attributes('data-category')).toBe('Céréales')
})
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.manage')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(true)
})
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(false)
})
it('navigue vers la consultation au clic sur une ligne', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('tr[data-row-id="34"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/admin/products/34')
})
it('navigue vers la création au clic sur « + Ajouter »', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('[data-label="admin.products.add"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/admin/products/new')
})
it('appelle l\'export XLSX sur /products/export.xlsx en blob', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('[data-label="admin.products.export"]').trigger('click')
await flushPromises()
expect(mockApiGet).toHaveBeenCalledWith(
'/products/export.xlsx',
expect.any(Object),
expect.objectContaining({ responseType: 'blob', toast: false }),
)
})
it('répercute les sites cochés dans setFilters (filtre multi, clé siteId[])', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ 'siteId[]': ['1'] },
{ replace: true },
)
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
expect(mockPush).not.toHaveBeenCalled()
})
it('répercute l\'état sélectionné dans setFilters (param state)', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('select[data-empty-label="admin.products.filters.stateAll"]').setValue('SALE')
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ state: 'SALE' },
{ replace: true },
)
})
it('répercute la catégorie sélectionnée dans setFilters (param categoryId)', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('select[data-empty-label="admin.products.filters.categoryAll"]').setValue('12')
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ categoryId: '12' },
{ replace: true },
)
})
it('badge filtres actifs + Réinitialiser vide l\'état appliqué', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
expect(wrapper.find('[data-label="admin.products.filters.title (1)"]').exists()).toBe(true)
// Réinitialiser → query propre (setFilters avec objet vide).
await wrapper.find('[data-label="admin.products.filters.reset"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
})
})
@@ -1,188 +0,0 @@
<template>
<div>
<!-- En-tete : retour vers le catalogue + nom du produit. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('admin.products.edit.back')"
v-bind="{ ariaLabel: t('admin.products.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.edit.notFound') }}</p>
<template v-else-if="product">
<!-- Formulaire principal pre-rempli (mêmes champs/regles que l'ajout,
RG-6.016.07). Bouton « Enregistrer » PATCH (RG-6.08). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
<MalioSelectCheckbox
:model-value="form.states"
:options="stateOptions"
:max-tags="3"
:label="t('admin.products.form.states')"
:display-tag="true"
:required="true"
:error="errors.states"
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
/>
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04). -->
<MalioSelectCheckbox
:model-value="form.siteIris"
:options="siteOptions"
:label="t('admin.products.form.sites')"
:display-tag="true"
:required="true"
:error="errors.sites"
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
/>
<MalioInputText
v-model="form.name"
:mask="FREE_TEXT_MASK"
:label="t('admin.products.form.name')"
:required="true"
:error="errors.name"
/>
<!-- Code modifiable techniquement ; l'unicite reste re-validee serveur (RG-6.01). -->
<MalioInputText
v-model="form.code"
:mask="CODE_ALNUM_MASK"
:label="t('admin.products.form.code')"
:required="true"
:error="errors.code"
/>
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
<MalioSelect
:model-value="form.categoryIri"
:options="categoryOptions"
:label="t('admin.products.form.category')"
empty-option-label=""
:required="true"
:error="errors.category"
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
/>
<!-- Type de stockage : multi-select obligatoire (>= 1). Referentiel plat :
tous les types (plus de filtrage par site, RG-6.06). -->
<MalioSelectCheckbox
:model-value="form.storageTypeIris"
:options="storageTypeOptions"
:max-tags="3"
:label="t('admin.products.form.storageTypes')"
:display-tag="true"
:required="true"
:error="errors.storageTypes"
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
/>
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
uniquement si l'Etat contient « Vendu ». -->
<MalioCheckbox
v-if="isSale"
v-model="form.manufactured"
:label="t('admin.products.form.manufactured')"
group-class="self-center"
/>
<MalioCheckbox
v-if="isSale"
v-model="form.containsMolasses"
:label="t('admin.products.form.containsMolasses')"
group-class="self-center"
/>
</div>
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('admin.products.edit.save')"
:disabled="submitting"
@click="onSubmit"
/>
</div>
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01). Visibilite
conditionnee par l'etat : Fournisseurs si Achat/Aucun, Clients si
Vendu/Aucun (cf. ProductPlaceholderTabs). -->
<ProductPlaceholderTabs :states="form.states" />
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
import { useProduct } from '~/modules/catalog/composables/useProduct'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
const productId = route.params.id as string
// Gating de la route : la modification est reservee a `manage` ; sinon retour
// consultation (la lecture seule reste accessible avec `view`).
if (!can('catalog.products.manage')) {
await navigateTo(`/admin/products/${productId}`)
}
const { product, loading, error, load } = useProduct(productId)
const {
form,
errors,
submitting,
isSale,
siteOptions,
categoryOptions,
storageTypeOptions,
setStates,
setCategory,
setStorageTypes,
setSites,
loadReferentials,
prefill,
submit,
} = useProductForm()
const headerTitle = computed(() => product.value?.name ?? t('admin.products.edit.title'))
useHead({ title: headerTitle })
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
const stateOptions = computed(() =>
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
)
/** Retour vers la consultation du produit (fleche d'en-tete). */
function goBack(): void {
router.push(`/admin/products/${productId}`)
}
/**
* Soumet la modification (PATCH). Au succes : on RESTE sur l'ecran d'edition
* (l'utilisateur garde la main, calque client/fournisseur) — le toast de succes et
* la reaffichage des valeurs normalisees sont geres par `submit()`. La navigation
* reste manuelle (fleche retour -> consultation).
*/
async function onSubmit(): Promise<void> {
await submit()
}
onMounted(async () => {
// Referentiels (selects) + detail du produit charges en parallele.
await Promise.all([
loadReferentials().catch(() => {}),
load(),
])
// Pre-remplissage une fois le produit charge (echec de chargement => message).
if (product.value) {
await prefill(product.value)
}
})
</script>
@@ -1,155 +0,0 @@
<template>
<div>
<!-- En-tete : retour catalogue + nom du produit + action « Modifier ». -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('admin.products.consultation.back')"
v-bind="{ ariaLabel: t('admin.products.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canManage"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('admin.products.action.edit')"
@click="goEdit"
/>
</div>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.consultation.notFound') }}</p>
<template v-else-if="product">
<!-- Bloc principal (lecture seule) meme disposition que l'ajout/edition.
Champs non remplis masques (ERP-193, isFilled). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(statesLabel)"
:model-value="statesLabel"
:label="t('admin.products.form.states')"
disabled
/>
<MalioInputText
v-if="isFilled(sitesLabel)"
:model-value="sitesLabel"
:label="t('admin.products.form.sites')"
disabled
/>
<MalioInputText
v-if="isFilled(product.name)"
:model-value="product.name"
:label="t('admin.products.form.name')"
disabled
/>
<MalioInputText
v-if="isFilled(product.code)"
:model-value="product.code"
:label="t('admin.products.form.code')"
disabled
/>
<MalioInputText
v-if="isFilled(categoryLabel)"
:model-value="categoryLabel"
:label="t('admin.products.form.category')"
disabled
/>
<MalioInputText
v-if="isFilled(storageTypesLabel)"
:model-value="storageTypesLabel"
:label="t('admin.products.form.storageTypes')"
disabled
/>
<!-- RG-6.03 : « Fabriqué » / « Contient de la mélasse » affiches
uniquement si l'etat contient « Vendu » ET la case est cochee. -->
<div v-if="isSale && isFilled(product.manufactured)" class="flex h-12 items-center">
<MalioCheckbox
id="product-view-manufactured"
:label="t('admin.products.form.manufactured')"
:model-value="product.manufactured"
disabled
:reserve-message-space="false"
/>
</div>
<div v-if="isSale && isFilled(product.containsMolasses)" class="flex h-12 items-center">
<MalioCheckbox
id="product-view-molasses"
:label="t('admin.products.form.containsMolasses')"
:model-value="product.containsMolasses"
disabled
:reserve-message-space="false"
/>
</div>
</div>
<!-- Pas d'onglet en consultation (ERP-193) : on masque les onglets vides.
Les onglets Fournisseurs / Clients sont des coquilles non implementees
(placeholder, module Contrat inexistant, HP-M6-01) => aucune donnee a
afficher, donc rien n'est rendu ici. Ils restent visibles a l'edition
(preview + regle d'etat). Quand le module Contrat existera, ce bloc
affichera les onglets effectivement remplis (calque client/fournisseur). -->
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useProduct } from '~/modules/catalog/composables/useProduct'
import { isFilled } from '~/shared/utils/consultationDisplay'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
// Gating de la route : la consultation est reservee a `view` (catalogue admin-only).
if (!can('catalog.products.view')) {
await navigateTo('/admin/products')
}
const productId = route.params.id as string
const { product, loading, error, load } = useProduct(productId)
// L'edition est reservee a `manage` ; le bouton « Modifier » suit cette permission.
const canManage = computed(() => can('catalog.products.manage'))
const headerTitle = computed(() => product.value?.name ?? t('admin.products.consultation.title'))
useHead({ title: t('admin.products.consultation.title') })
// RG-6.03 : « Vendu » conditionne l'affichage des booleens fabriqué / mélasse.
const isSale = computed(() => product.value?.states.includes('SALE') ?? false)
// ── Libelles lecture seule (relations embarquees mappees en texte) ───────────
const statesLabel = computed(() =>
(product.value?.states ?? []).map(code => t(`admin.products.state.${code}`)).join(', '),
)
const sitesLabel = computed(() =>
(product.value?.sites ?? []).map(site => site.name).join(', '),
)
const categoryLabel = computed(() => product.value?.category?.name ?? '')
const storageTypesLabel = computed(() =>
(product.value?.storageTypes ?? []).map(type => type.label).join(', '),
)
/** Retour vers le catalogue produit (fleche d'en-tete). */
function goBack(): void {
router.push('/admin/products')
}
/** Bascule vers l'ecran de modification. */
function goEdit(): void {
router.push(`/admin/products/${productId}/edit`)
}
onMounted(load)
</script>
@@ -1,377 +0,0 @@
<template>
<div>
<PageHeader>
{{ t('admin.products.title') }}
<template #actions>
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter (meme
design que le Repertoire transporteurs / la Gestion des categories). -->
<div class="flex items-center gap-8">
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('admin.products.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList : pagination serveur, tri
name ASC par defaut (cote back, § 4.1). Colonnes Nom / Numero /
Categorie (docx p.3). -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
:empty-message="t('admin.products.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
/>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('admin.products.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Voir les résultats ». Meme pattern que les repertoires M1M5.
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('admin.products.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche : code + nom (param `search`, partiel insensible a la casse). -->
<MalioAccordionItem :title="t('admin.products.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Categorie : select simple (param `categoryId`). Referentiel borne
aux categories de type PRODUIT (RG-6.05). -->
<MalioAccordionItem :title="t('admin.products.filters.category')" value="category">
<MalioSelect
:model-value="draftCategoryId"
:options="categoryOptions"
:empty-option-label="t('admin.products.filters.categoryAll')"
@update:model-value="(v: string | number | null) => draftCategoryId = v === null || v === '' ? null : Number(v)"
/>
</MalioAccordionItem>
<!-- Etat : select simple (param `state`, enum PURCHASE / SALE / OTHER). -->
<MalioAccordionItem :title="t('admin.products.filters.state')" value="state">
<MalioSelect
:model-value="draftState"
:options="stateOptions"
:empty-option-label="t('admin.products.filters.stateAll')"
@update:model-value="(v: string | number | null) => draftState = v === null || v === '' ? null : String(v)"
/>
</MalioAccordionItem>
<!-- Site(s) : cases a cocher (multi, param `siteId[]`). Un produit
remonte s'il est disponible sur AU MOINS UN des sites coches. -->
<MalioAccordionItem :title="t('admin.products.filters.site')" value="site">
<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>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('admin.products.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('admin.products.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Product } from '~/modules/catalog/types/product'
interface FilterOption {
value: number
label: string
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('admin.products.title') })
// Catalogue produit admin-only (docx p.3) : « + Ajouter » reserve a `manage`.
// « Filtrer » / « Exporter » suivent `view` (gate page-level). L'item sidebar
// est deja masque cote back pour les roles sans `view` (RBAC § 5.2).
const canManage = computed(() => can('catalog.products.manage'))
const canView = computed(() => can('catalog.products.view'))
// Pagination serveur via le composable partage. Le ProductProvider applique
// deja name ASC (§ 4.1) — pas de defaultSort cote front tant qu'aucun
// OrderFilter n'est expose.
const {
items: products,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadProducts,
goToPage,
setItemsPerPage,
setFilters,
} = usePaginatedList<Product>({ url: '/products' })
// Mappe les produits en objets « plats » pour MalioDataTable (items typees
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
// implicite, contrairement a l'interface Product. Meme pattern que M1→M5.
const rows = computed(() => products.value.map(product => ({
id: product.id,
name: product.name,
code: product.code,
categoryName: product.category?.name ?? '',
})))
const columns = [
{ key: 'name', label: t('admin.products.column.name') },
{ key: 'code', label: t('admin.products.column.code') },
{ key: 'categoryName', label: t('admin.products.column.category') },
]
/** Clic sur une ligne → ecran de consultation (lecture seule) /admin/products/{id}. */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/admin/products/${item.id}`)
}
function goToCreate(): void {
router.push('/admin/products/new')
}
// ── Referentiels des filtres ─────────────────────────────────────────────────
// Charges une fois (pagination desactivee, referentiels bornes). Categories
// filtrees au type PRODUIT (RG-6.05) ; sites = tous les sites actifs.
const categoryOptions = ref<FilterOption[]>([])
const siteOptions = ref<FilterOption[]>([])
// Etats produit (miroir de l'enum back Product::STATE_*). Le libelle est resolu
// par i18n. Select simple cote filtre (`?state=` n'accepte qu'une valeur).
const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
const stateOptions = computed(() =>
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
)
interface HydraMember { '@id': string, id: number, name?: string, postalCode?: string }
/** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>(
url: string,
query: Record<string, string> = {},
): Promise<T[]> {
const res = await api.get<{ member?: T[] }>(
url,
{ pagination: 'false', ...query },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
return res.member ?? []
}
/**
* Charge les referentiels des filtres en parallele et de maniere resiliente :
* un referentiel en echec (403/500) reste vide sans casser l'autre.
*/
async function loadFilterReferentials(): Promise<void> {
await Promise.allSettled([
fetchAll('/categories', { typeCode: 'PRODUIT' })
.then((cats) => { categoryOptions.value = cats.map(c => ({ value: c.id, label: c.name ?? '' })) }),
fetchAll('/sites')
.then((sitesList) => { siteOptions.value = sitesList.map(s => ({ value: s.id, label: s.name ?? '' })) }),
])
}
// ── Filtres (drawer) ─────────────────────────────────────────────────────────
// Deux niveaux d'etat (pattern repertoires M1→M5) :
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryId = ref<number | null>(null)
const draftState = ref<string | null>(null)
const draftSiteIds = ref<number[]>([])
const appliedSearch = ref('')
const appliedCategoryId = ref<number | null>(null)
const appliedState = ref<string | null>(null)
const appliedSiteIds = ref<number[]>([])
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryId.value !== null) count++
if (appliedState.value !== null) count++
if (appliedSiteIds.value.length > 0) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('admin.products.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
draftCategoryId.value = appliedCategoryId.value
draftState.value = appliedState.value
draftSiteIds.value = [...appliedSiteIds.value]
filterDrawerOpen.value = true
}
/** Coche / decoche un site dans le brouillon (filtre multi). */
function toggleSite(id: number, 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. Cle
* `siteId[]` pour que PHP la parse en tableau (OR cote back). Les filtres vides
* sont omis pour une query propre.
*/
function buildFilterPayload(): Record<string, string | string[]> {
const payload: Record<string, string | string[]> = {}
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryId.value !== null) payload.categoryId = String(appliedCategoryId.value)
if (appliedState.value !== null) payload.state = appliedState.value
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = appliedSiteIds.value.map(String)
return payload
}
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryId.value = draftCategoryId.value
appliedState.value = draftState.value
appliedSiteIds.value = [...draftSiteIds.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 = ''
draftCategoryId.value = null
draftState.value = null
draftSiteIds.value = []
appliedSearch.value = ''
appliedCategoryId.value = null
appliedState.value = null
appliedSiteIds.value = []
setFilters({}, { replace: true })
}
// ── Export XLSX ──────────────────────────────────────────────────────────────
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
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 (meme pattern M2→M5).
const blob = await api.get<Blob>('/products/export.xlsx', buildFilterPayload(), {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'catalogue-produits.xlsx')
}
catch {
toast.error({
title: t('admin.products.toast.error'),
message: t('admin.products.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(() => {
loadProducts()
loadFilterReferentials()
})
</script>
@@ -1,162 +0,0 @@
<template>
<div>
<!-- En-tete : retour vers le catalogue + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('admin.products.form.back')"
v-bind="{ ariaLabel: t('admin.products.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('admin.products.form.title') }}</h1>
</div>
<!-- Formulaire principal de creation
Bouton « Valider » TOUJOURS actif (ERP-101) : la validation
autoritaire est serveur, les erreurs 422 reviennent inline. -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
<MalioSelectCheckbox
:model-value="form.states"
:options="stateOptions"
:max-tags="3"
:label="t('admin.products.form.states')"
:display-tag="true"
:required="true"
:error="errors.states"
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
/>
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04). -->
<MalioSelectCheckbox
:model-value="form.siteIris"
:options="siteOptions"
:label="t('admin.products.form.sites')"
:display-tag="true"
:required="true"
:error="errors.sites"
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
/>
<MalioInputText
v-model="form.name"
:mask="FREE_TEXT_MASK"
:label="t('admin.products.form.name')"
:required="true"
:error="errors.name"
/>
<MalioInputText
v-model="form.code"
:mask="CODE_ALNUM_MASK"
:label="t('admin.products.form.code')"
:required="true"
:error="errors.code"
/>
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
<MalioSelect
:model-value="form.categoryIri"
:options="categoryOptions"
:label="t('admin.products.form.category')"
empty-option-label=""
:required="true"
:error="errors.category"
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
/>
<!-- Type de stockage : multi-select obligatoire (>= 1). Referentiel plat :
tous les types (plus de filtrage par site, RG-6.06). -->
<MalioSelectCheckbox
:model-value="form.storageTypeIris"
:options="storageTypeOptions"
:max-tags="3"
:label="t('admin.products.form.storageTypes')"
:display-tag="true"
:required="true"
:error="errors.storageTypes"
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
/>
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
uniquement si l'Etat contient « Vendu ». -->
<MalioCheckbox
v-if="isSale"
v-model="form.manufactured"
:label="t('admin.products.form.manufactured')"
group-class="self-center"
/>
<MalioCheckbox
v-if="isSale"
v-model="form.containsMolasses"
:label="t('admin.products.form.containsMolasses')"
group-class="self-center"
/>
</div>
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('admin.products.form.submit')"
:disabled="submitting"
@click="onSubmit"
/>
</div>
<!-- Onglets Fournisseurs / Clients (placeholder, HP-M6-01) : NON affiches a
l'ajout. Ils n'apparaissent qu'apres validation du formulaire principal
(ecran de modification), une fois le produit cree. -->
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
const { t } = useI18n()
const router = useRouter()
const { can } = usePermissions()
useHead({ title: t('admin.products.form.title') })
// Gating de la route : la creation est reservee a `manage` (catalogue admin-only).
if (!can('catalog.products.manage')) {
await navigateTo('/admin/products')
}
const {
form,
errors,
submitting,
isSale,
siteOptions,
categoryOptions,
storageTypeOptions,
setStates,
setCategory,
setStorageTypes,
setSites,
loadReferentials,
submit,
} = useProductForm()
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
const stateOptions = computed(() =>
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
)
/** Retour vers le catalogue produit (fleche d'en-tete). */
function goBack(): void {
router.push('/admin/products')
}
/** Soumet la creation ; au succes, retour a la liste. */
async function onSubmit(): Promise<void> {
const ok = await submit()
if (ok) {
router.push('/admin/products')
}
}
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
loadReferentials().catch(() => {})
})
</script>
-72
View File
@@ -1,72 +0,0 @@
/**
* Types front du module Catalog (M6 — Catalogue produit).
*
* Contrats API consommes :
* - GET /api/products → HydraCollection<Product>
* - GET /api/products/{id} → Product
* - GET /api/products/export.xlsx → binaire XLSX (export complet, filtres actifs)
*
* Notes (cf. spec-back § 4.0.bis, contrat JSON capture en ERP-203) :
* - `category` est embarque (objet, pas IRI) ; idem `sites` / `storageTypes`
* (tableaux d'objets bornes). On n'a besoin que de `category.name` en liste.
* - `states` est un tableau de chaines (PURCHASE / SALE / OTHER).
* - `skip_null_values` actif cote back : ne pas presumer la presence des nulls.
*/
/** Type de categorie embarque dans `category.categoryTypes` (RG-6.05). */
export interface ProductCategoryType {
id: number
code: string
label: string
}
/** Categorie embarquee dans un produit (lecture seule, sous-ensemble utile au front). */
export interface ProductCategory {
/** IRI Hydra, ex. `/api/categories/12` — utilise pour pre-selectionner le select en edition. */
'@id': string
id: number
name: string
code: string
categoryTypes?: ProductCategoryType[]
}
/** Site de disponibilite embarque dans un produit (groupe `site:read`). */
export interface ProductSite {
/** IRI Hydra, ex. `/api/sites/1` — utilise pour pre-selectionner le multi-select en edition. */
'@id': string
id: number
name: string
code: string
postalCode: string
city: string
color: string
fullAddress: string
}
/** Type de stockage embarque dans un produit (referentiel borne, § 2.4). */
export interface ProductStorageType {
/** IRI Hydra, ex. `/api/storage_types/9` — utilise pour pre-selectionner le multi-select en edition. */
'@id': string
id: number
code: string
label: string
}
/**
* Produit metier — tel qu'il est lu depuis l'API. L'entite porte le pattern
* Timestampable+Blamable (cf. spec-back § 2.8).
*/
export interface Product {
id: number
code: string
name: string
/** Etats : sous-ensemble de PURCHASE / SALE / OTHER (RG-6.02). */
states: string[]
manufactured: boolean
containsMolasses: boolean
category: ProductCategory | null
sites: ProductSite[]
storageTypes: ProductStorageType[]
createdAt: string
updatedAt: string
}
@@ -1,23 +1,15 @@
<template> <template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant <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)]">
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }" v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
@click="$emit('remove')" @click="$emit('remove')"
/> />
</div>
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur) <!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
remplacant les 3 cases. Les options encodent les combinaisons valides remplacant les 3 cases. Les options encodent les combinaisons valides
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les (exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
@@ -29,8 +21,7 @@
:options="addressTypeOptions" :options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')" :label="t('commercial.clients.form.address.addressType')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.isProspect" :error="errors?.isProspect"
@update:model-value="onAddressTypeChange" @update:model-value="onAddressTypeChange"
/> />
@@ -42,22 +33,17 @@
:label="t('commercial.clients.form.address.sites')" :label="t('commercial.clients.form.address.sites')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.sites" :error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris" :model-value="model.contactIris"
:options="contactOptions" :options="contactOptions"
:max-tags="3"
:label="t('commercial.clients.form.address.contacts')" :label="t('commercial.clients.form.address.contacts')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
@@ -69,9 +55,8 @@
v-if="isBillingEmailRequired(model)" v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail" :model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')" :label="t('commercial.clients.form.address.billingEmail')"
:required="!readonly && !disabled" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.billingEmail" :error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly" :addable="!model.hasSecondaryBillingEmail && !readonly"
@@ -79,17 +64,13 @@
@update:model-value="(v: string) => update('billingEmail', v)" @update:model-value="(v: string) => update('billingEmail', v)"
@add="revealSecondaryBillingEmail" @add="revealSecondaryBillingEmail"
/> />
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne). <div v-else aria-hidden="true" />
Inutile en consultation masquee (la grille se recompose sans les
champs vides, ERP-193). -->
<div v-else-if="!hideEmpty" aria-hidden="true" />
<MalioInputEmail <MalioInputEmail
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail" v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
:model-value="model.billingEmailSecondary" :model-value="model.billingEmailSecondary"
:label="t('commercial.clients.form.address.billingEmailSecondary')" :label="t('commercial.clients.form.address.billingEmailSecondary')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.billingEmailSecondary" :error="errors?.billingEmailSecondary"
@update:model-value="(v: string) => update('billingEmailSecondary', v)" @update:model-value="(v: string) => update('billingEmailSecondary', v)"
@@ -98,12 +79,10 @@
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="model.categoryIris" :model-value="model.categoryIris"
:options="categoryOptions" :options="categoryOptions"
:max-tags="3"
:label="t('commercial.clients.form.address.categories')" :label="t('commercial.clients.form.address.categories')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.categories" :error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -113,8 +92,7 @@
:options="countryOptions" :options="countryOptions"
:label="t('commercial.clients.form.address.country')" :label="t('commercial.clients.form.address.country')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
@@ -123,8 +101,7 @@
:label="t('commercial.clients.form.address.postalCode')" :label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -138,20 +115,17 @@
:options="cityOptions" :options="cityOptions"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="!readonly && !disabled" :required="true"
:error="errors?.city" :error="errors?.city"
@update:model-value="onCityChange" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
v-else v-else
:model-value="model.city" :model-value="model.city"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -168,15 +142,14 @@
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. --> pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly && !disabled" v-if="!readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('commercial.clients.form.address.streetNotFound')" :no-results-text="t('commercial.clients.form.address.streetNotFound')"
@@ -189,25 +162,22 @@
:model-value="model.street" :model-value="model.street"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1"> <div class="col-span-1">
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')" :label="t('commercial.clients.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
</div> </div>
</div>
</div> </div>
</template> </template>
@@ -219,10 +189,8 @@ import {
type AddressType, type AddressType,
} from '~/modules/commercial/utils/forms/clientFormRules' } from '~/modules/commercial/utils/forms/clientFormRules'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/types/referentials' import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm' import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque code postal FR : 5 chiffres. // Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####' const POSTAL_CODE_MASK = '#####'
@@ -240,13 +208,7 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -322,37 +284,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
// les champs texte editables (complement, ville en mode degrade). La voie en
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
// via Assert\Regex) ; les emails de facturation valident leur format (Assert\Email).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void { function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/**
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
* a chaque frappe).
*/
function onCityChange(value: string | number | null): void {
const next = value === null ? null : String(value)
if (next === (props.modelValue.city ?? null)) {
return
}
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
city: next,
street: null,
streetComplement: null,
})
}
/** Revele le 2e champ email de facturation (clic sur le « + »). */ /** Revele le 2e champ email de facturation (clic sur le « + »). */
function revealSecondaryBillingEmail(): void { function revealSecondaryBillingEmail(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true }) emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
@@ -368,27 +304,9 @@ function notifyUnavailable(): void {
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */ /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> { async function onPostalCodeChange(value: string): Promise<void> {
const digits = (value ?? '').replace(/\D/g, '')
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
if (digits.length === 5 && digits !== previousDigits) {
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
postalCode: value,
city: null,
street: null,
streetComplement: null,
})
}
else {
update('postalCode', value) update('postalCode', value)
}
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) { if (digits.length < 5) {
return return
} }
@@ -1,76 +1,56 @@
<template> <template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant <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)]">
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule. non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }" v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
@click="$emit('remove')" @click="$emit('remove')"
/> />
</div>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName" :model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')" :label="t('commercial.clients.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName" :model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')" :label="t('commercial.clients.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> <div class="col-span-2">
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')" :label="t('commercial.clients.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
</div> </div>
<MalioInputEmail <MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email" :model-value="model.email"
:label="t('commercial.clients.form.contact.email')" :label="t('commercial.clients.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
/> />
<MalioInputPhone <MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary" :model-value="model.phonePrimary"
:label="t('commercial.clients.form.contact.phonePrimary')" :label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')" :add-button-label="t('commercial.clients.form.contact.addPhone')"
@@ -78,23 +58,19 @@
@add="revealSecondaryPhone" @add="revealSecondaryPhone"
/> />
<MalioInputPhone <MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))" v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary" :model-value="model.phoneSecondary"
:label="t('commercial.clients.form.contact.phoneSecondary')" :label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm' import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste // Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee). // serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
@@ -107,14 +83,8 @@ const props = defineProps<{
title: string title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */ /** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -129,10 +99,6 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template. // Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void { function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -1,23 +1,15 @@
<template> <template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant <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)]">
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : modal de confirmation cote parent. --> <!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }" v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
@click="$emit('remove')" @click="$emit('remove')"
/> />
</div>
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant <!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
`addressType`) s'affiche via la prop native :error de MalioSelect. --> `addressType`) s'affiche via la prop native :error de MalioSelect. -->
@@ -26,9 +18,8 @@
:options="addressTypeOptions" :options="addressTypeOptions"
:label="t('commercial.suppliers.form.address.addressType')" :label="t('commercial.suppliers.form.address.addressType')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="!readonly && !disabled" :required="true"
:error="errors?.addressType" :error="errors?.addressType"
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))" @update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
/> />
@@ -40,40 +31,33 @@
:label="t('commercial.suppliers.form.address.sites')" :label="t('commercial.suppliers.form.address.sites')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.sites" :error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
<!-- Contacts rattaches (M2M, facultatif). --> <!-- Contacts rattaches (M2M, facultatif). -->
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris" :model-value="model.contactIris"
:options="contactOptions" :options="contactOptions"
:max-tags="3"
:label="t('commercial.suppliers.form.address.contacts')" :label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client <!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
porte ici l'email de facturation, absent cote fournisseur). Inutile en porte ici l'email de facturation, absent cote fournisseur). -->
consultation masquee (la grille se recompose sans les champs vides). --> <div aria-hidden="true" />
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). --> <!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="model.categoryIris" :model-value="model.categoryIris"
:options="categoryOptions" :options="categoryOptions"
:max-tags="3"
:label="t('commercial.suppliers.form.address.categories')" :label="t('commercial.suppliers.form.address.categories')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.categories" :error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -83,8 +67,7 @@
:options="countryOptions" :options="countryOptions"
:label="t('commercial.suppliers.form.address.country')" :label="t('commercial.suppliers.form.address.country')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
@@ -93,8 +76,7 @@
:label="t('commercial.suppliers.form.address.postalCode')" :label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -106,20 +88,17 @@
:options="cityOptions" :options="cityOptions"
:label="t('commercial.suppliers.form.address.city')" :label="t('commercial.suppliers.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="!readonly && !disabled" :required="true"
:error="errors?.city" :error="errors?.city"
@update:model-value="onCityChange" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
v-else v-else
:model-value="model.city" :model-value="model.city"
:label="t('commercial.suppliers.form.address.city')" :label="t('commercial.suppliers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -128,15 +107,14 @@
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). --> texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2"> <div class="col-span-2">
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly && !disabled" v-if="!readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('commercial.suppliers.form.address.street')" :label="t('commercial.suppliers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')" :no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
@@ -148,62 +126,49 @@
v-else v-else
:model-value="model.street" :model-value="model.street"
:label="t('commercial.suppliers.form.address.street')" :label="t('commercial.suppliers.form.address.street')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1"> <div class="col-span-1">
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')" :label="t('commercial.suppliers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
</div> </div>
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0 <!-- Bennes : stepper (specifique fournisseur, defaut 0). -->
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
<MalioInputNumber <MalioInputNumber
v-if="!hideEmpty || isFilled(model.bennes)"
:model-value="model.bennes" :model-value="model.bennes"
:label="t('commercial.suppliers.form.address.bennes')" :label="t('commercial.suppliers.form.address.bennes')"
:min="0" :min="0"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.bennes" :error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)" @update:model-value="(v: string) => update('bennes', v)"
/> />
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). <!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). -->
Consultation : masquee si non cochee (ERP-193). -->
<MalioCheckbox <MalioCheckbox
v-if="!hideEmpty || isFilled(model.triageProvider)"
id="address-triage-provider" id="address-triage-provider"
:label="t('commercial.suppliers.form.address.triageProvider')" :label="t('commercial.suppliers.form.address.triageProvider')"
:model-value="model.triageProvider" :model-value="model.triageProvider"
group-class="self-center" group-class="self-center"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: boolean) => update('triageProvider', v)" @update:model-value="(v: boolean) => update('triageProvider', v)"
/> />
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/types/referentials' import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm' import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque code postal FR : 5 chiffres. // Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####' const POSTAL_CODE_MASK = '#####'
@@ -221,13 +186,7 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -279,37 +238,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
// les champs texte editables (complement, ville en mode degrade, voie en repli). La
// voie en autocomplete (BAN) et la ville en select ne sont pas masquees (le back
// valide via Assert\Regex).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void { function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/**
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
* a chaque frappe).
*/
function onCityChange(value: string | number | null): void {
const next = value === null ? null : String(value)
if (next === (props.modelValue.city ?? null)) {
return
}
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
city: next,
street: null,
streetComplement: null,
})
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */ /** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void { function notifyUnavailable(): void {
if (!unavailableNotified) { if (!unavailableNotified) {
@@ -320,27 +253,9 @@ function notifyUnavailable(): void {
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */ /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> { async function onPostalCodeChange(value: string): Promise<void> {
const digits = (value ?? '').replace(/\D/g, '')
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
if (digits.length === 5 && digits !== previousDigits) {
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
postalCode: value,
city: null,
street: null,
streetComplement: null,
})
}
else {
update('postalCode', value) update('postalCode', value)
}
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) { if (digits.length < 5) {
return return
} }
@@ -1,75 +1,55 @@
<template> <template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant <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)]">
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc, RG-2.13) ou en lecture seule. --> non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }" v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
@click="$emit('remove')" @click="$emit('remove')"
/> />
</div>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName" :model-value="model.lastName"
:label="t('commercial.suppliers.form.contact.lastName')" :label="t('commercial.suppliers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName" :model-value="model.firstName"
:label="t('commercial.suppliers.form.contact.firstName')" :label="t('commercial.suppliers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> <div class="col-span-2">
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')" :label="t('commercial.suppliers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
</div> </div>
<MalioInputEmail <MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email" :model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')" :label="t('commercial.suppliers.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
/> />
<MalioInputPhone <MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary" :model-value="model.phonePrimary"
:label="t('commercial.suppliers.form.contact.phonePrimary')" :label="t('commercial.suppliers.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.suppliers.form.contact.addPhone')" :add-button-label="t('commercial.suppliers.form.contact.addPhone')"
@@ -77,23 +57,19 @@
@add="revealSecondaryPhone" @add="revealSecondaryPhone"
/> />
<MalioInputPhone <MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))" v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary" :model-value="model.phoneSecondary"
:label="t('commercial.suppliers.form.contact.phoneSecondary')" :label="t('commercial.suppliers.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm' import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur). // Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
@@ -105,14 +81,8 @@ const props = defineProps<{
title: string title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */ /** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -127,10 +97,6 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template. // Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void { function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -171,182 +171,6 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
}) })
}) })
/**
* Stub MalioInputText emetteur : re-expose `label` et relaie `update:model-value`,
* pour piloter le champ Code postal et observer le brouillon emis.
*/
const MalioInputTextEmitter = defineComponent({
name: 'MalioInputTextEmitter',
props: {
modelValue: { type: [String, Number, null], default: undefined },
label: { type: String, default: '' },
},
emits: ['update:modelValue'],
setup(props) {
return () => h('div', { 'data-testid': 'addr-input', 'data-label': props.label })
},
})
describe('ClientAddressBlock — changement de code postal vide les champs dependants (ERP-193)', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchCityMock.mockResolvedValue([])
})
function mountFilled() {
return mount(ClientAddressBlock, {
props: {
modelValue: {
...emptyAddress(),
postalCode: '75001',
city: 'Paris',
street: '8 Boulevard du Port',
streetComplement: 'Bat A',
},
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
MalioInputText: MalioInputTextEmitter,
},
},
})
}
function postalCodeField(wrapper: ReturnType<typeof mountFilled>) {
return wrapper.findAllComponents(MalioInputTextEmitter).find(
c => c.props('label') === 'commercial.clients.form.address.postalCode',
)
}
it('vide ville, adresse et complement quand le CP complet change', async () => {
const wrapper = mountFilled()
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '33000')
await flushPromises()
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
expect(last.postalCode).toBe('33000')
expect(last.city).toBeNull()
expect(last.street).toBeNull()
expect(last.streetComplement).toBeNull()
})
it('ne vide pas les champs si le CP reste incomplet (< 5 chiffres)', async () => {
const wrapper = mountFilled()
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '7500')
await flushPromises()
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
expect(last.postalCode).toBe('7500')
expect(last.city).toBe('Paris')
expect(last.street).toBe('8 Boulevard du Port')
expect(last.streetComplement).toBe('Bat A')
})
it('ne vide pas les champs si le CP complet est identique', async () => {
const wrapper = mountFilled()
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '75001')
await flushPromises()
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
expect(last.city).toBe('Paris')
expect(last.street).toBe('8 Boulevard du Port')
expect(last.streetComplement).toBe('Bat A')
})
})
/**
* Stub MalioSelect emetteur : re-expose `label` et relaie `update:model-value`,
* pour piloter le select Ville et observer le brouillon emis.
*/
const MalioSelectEmitter = defineComponent({
name: 'MalioSelectEmitter',
props: {
modelValue: { type: [String, Number, null], default: undefined },
label: { type: String, default: '' },
},
emits: ['update:modelValue'],
setup(props) {
return () => h('div', { 'data-testid': 'addr-select', 'data-label': props.label })
},
})
describe('ClientAddressBlock — changement de ville vide adresse + complement (ERP-193)', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchCityMock.mockResolvedValue([])
})
function mountFilled() {
return mount(ClientAddressBlock, {
props: {
modelValue: {
...emptyAddress(),
postalCode: '75001',
city: 'Paris',
street: '8 Boulevard du Port',
streetComplement: 'Bat A',
},
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
MalioSelect: MalioSelectEmitter,
},
},
})
}
function cityField(wrapper: ReturnType<typeof mountFilled>) {
return wrapper.findAllComponents(MalioSelectEmitter).find(
c => c.props('label') === 'commercial.clients.form.address.city',
)
}
it('vide adresse et complement quand la ville change', async () => {
const wrapper = mountFilled()
cityField(wrapper)!.vm.$emit('update:modelValue', 'Lyon')
await flushPromises()
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
expect(last.city).toBe('Lyon')
expect(last.street).toBeNull()
expect(last.streetComplement).toBeNull()
})
it('ne vide pas si la ville selectionnee est identique', async () => {
const wrapper = mountFilled()
cityField(wrapper)!.vm.$emit('update:modelValue', 'Paris')
await flushPromises()
// Aucun nouvel emit (valeur inchangee) → l'adresse reste intacte.
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => { describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
beforeEach(() => { beforeEach(() => {
searchAddressMock.mockReset() searchAddressMock.mockReset()
@@ -45,7 +45,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
// Resilience : les referentiels OK sont peuples malgre l'echec de /categories. // 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). // 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', textColor: '#FFFFFF' }]) 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.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
// Pays : value = nom du pays (et non l'IRI). // Pays : value = nom du pays (et non l'IRI).
@@ -63,7 +63,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
}) })
} }
if (url === '/sites') { if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100', color: '#FF0000' }] }) return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
} }
return Promise.resolve({ member: [] }) return Promise.resolve({ member: [] })
}) })
@@ -74,27 +74,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.categories.value).toEqual([ expect(refs.categories.value).toEqual([
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' }, { value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
]) ])
// Le libelle d'un site est son numero de departement (2 premiers chiffres du // Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
// code postal) ; la couleur du site est reportee (fond) avec un texte blanc. expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86', color: '#FF0000', textColor: '#FFFFFF' }])
})
it('separe les categories CLIENT (formulaire) des categories ADRESSE (blocs adresse)', async () => {
// Le mock distingue les deux appels /categories par leur filtre typeCode.
mockGet.mockImplementation((url: string, query?: Record<string, unknown>) => {
if (url === '/categories' && query?.typeCode === 'CLIENT') {
return Promise.resolve({ member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }] })
}
if (url === '/categories' && query?.typeCode === 'ADRESSE') {
return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'SIEGE', name: 'Siège' }] })
}
return Promise.resolve({ member: [] })
})
const refs = useClientReferentials()
await refs.loadCommon()
expect(refs.categories.value).toEqual([{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' }])
expect(refs.addressCategories.value).toEqual([{ value: '/api/categories/9', label: 'Siège', code: 'SIEGE' }])
}) })
}) })
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Client> {
describe('useClientsRepository', () => { describe('useClientsRepository', () => {
beforeEach(() => { beforeEach(() => {
mockGet.mockReset() mockGet.mockReset()
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2. // 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(60)) mockGet.mockResolvedValue(makeHydra(25))
}) })
it('cible la ressource /clients en page 1 par defaut', async () => { it('cible la ressource /clients en page 1 par defaut', async () => {
@@ -35,7 +35,7 @@ describe('useClientsRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/clients', '/clients',
{ page: 1, itemsPerPage: 25 }, { page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -65,7 +65,7 @@ describe('useClientsRepository', () => {
'siteId[]': ['1', '2'], 'siteId[]': ['1', '2'],
archivedOnly: true, archivedOnly: true,
page: 1, page: 1,
itemsPerPage: 25, itemsPerPage: 10,
}, },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
@@ -78,7 +78,7 @@ describe('useClientsRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/clients', '/clients',
{ page: 1, itemsPerPage: 25 }, { page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -23,16 +23,6 @@ describe('useSupplierReferentials', () => {
) )
}) })
it('charge les categories d\'adresse filtrees sur le type ADRESSE', async () => {
await useSupplierReferentials().loadCommon()
expect(mockGet).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({ pagination: 'false', typeCode: 'ADRESSE' }),
expect.objectContaining({ toast: false }),
)
})
it('mappe les categories en options { value: IRI, label: name, code }', async () => { it('mappe les categories en options { value: IRI, label: name, code }', async () => {
mockGet.mockImplementation((url: string) => { mockGet.mockImplementation((url: string) => {
if (url === '/categories') { if (url === '/categories') {
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Supplier> {
describe('useSuppliersRepository', () => { describe('useSuppliersRepository', () => {
beforeEach(() => { beforeEach(() => {
mockGet.mockReset() mockGet.mockReset()
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2. // 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(60)) mockGet.mockResolvedValue(makeHydra(25))
}) })
it('cible la ressource /suppliers en page 1 par defaut', async () => { it('cible la ressource /suppliers en page 1 par defaut', async () => {
@@ -35,7 +35,7 @@ describe('useSuppliersRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers', '/suppliers',
{ page: 1, itemsPerPage: 25 }, { page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -65,7 +65,7 @@ describe('useSuppliersRepository', () => {
'siteId[]': ['86', '17'], 'siteId[]': ['86', '17'],
archivedOnly: true, archivedOnly: true,
page: 1, page: 1,
itemsPerPage: 25, itemsPerPage: 10,
}, },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
@@ -78,7 +78,7 @@ describe('useSuppliersRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers', '/suppliers',
{ page: 1, itemsPerPage: 25 }, { page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -1,5 +1,4 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { CategoryOption, ClientOption, PaymentTypeOption, RefOption } from '~/modules/commercial/types/referentials'
/** /**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran * Charge les referentiels (listes courtes) alimentant les selects de l'ecran
@@ -16,6 +15,25 @@ import type { CategoryOption, ClientOption, PaymentTypeOption, RefOption } from
* Etat 100 % local a l'instance (refs) — aucune persistance URL. * 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 { interface HydraMember {
'@id': string '@id': string
} }
@@ -28,7 +46,6 @@ interface CategoryMember extends HydraMember {
interface SiteMember extends HydraMember { interface SiteMember extends HydraMember {
name: string name: string
postalCode: string postalCode: string
color?: string
} }
interface ReferentialMember extends HydraMember { interface ReferentialMember extends HydraMember {
@@ -51,9 +68,6 @@ export function useClientReferentials() {
const api = useApi() const api = useApi()
const categories = ref<CategoryOption[]>([]) const categories = ref<CategoryOption[]>([])
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
// CLIENT du formulaire principal.
const addressCategories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([]) const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([]) const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([]) const paymentDelays = ref<RefOption[]>([])
@@ -95,14 +109,11 @@ export function useClientReferentials() {
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API. // de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' }) fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code // Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja // postal du site), ex: 86100 -> « 86 ». Le code postal est deja
// expose par /sites (groupe site:read) — aucune colonne a ajouter. // 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), color: s.color, textColor: '#FFFFFF' })) }), .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
fetchAll<ReferentialMember>('/tva_modes') fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays') fetchAll<ReferentialMember>('/payment_delays')
@@ -140,7 +151,6 @@ export function useClientReferentials() {
return { return {
categories, categories,
addressCategories,
sites, sites,
tvaModes, tvaModes,
paymentDelays, paymentDelays,
@@ -49,6 +49,5 @@ export interface Client {
* gerer. * gerer.
*/ */
export function useClientsRepository() { export function useClientsRepository() {
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193). return usePaginatedList<Client>({ url: '/clients' })
return usePaginatedList<Client>({ url: '/clients', defaultItemsPerPage: 25 })
} }
@@ -1,5 +1,4 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { CategoryOption, PaymentTypeOption, RefOption } from '~/modules/commercial/types/referentials'
/** /**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran * Charge les referentiels (listes courtes) alimentant les selects de l'ecran
@@ -17,6 +16,22 @@ import type { CategoryOption, PaymentTypeOption, RefOption } from '~/modules/com
* Etat 100 % local a l'instance (refs) — aucune persistance URL. * Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/ */
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
}
/** Option de type de reglement enrichie de son code stable (RG-2.07 / RG-2.08). */
export interface PaymentTypeOption extends RefOption {
code: string
}
/** Option de categorie enrichie de son code stable. */
export interface CategoryOption extends RefOption {
code: string
}
interface HydraMember { interface HydraMember {
'@id': string '@id': string
} }
@@ -29,7 +44,6 @@ interface CategoryMember extends HydraMember {
interface SiteMember extends HydraMember { interface SiteMember extends HydraMember {
name: string name: string
postalCode: string postalCode: string
color?: string
} }
interface ReferentialMember extends HydraMember { interface ReferentialMember extends HydraMember {
@@ -48,9 +62,6 @@ export function useSupplierReferentials() {
const api = useApi() const api = useApi()
const categories = ref<CategoryOption[]>([]) const categories = ref<CategoryOption[]>([])
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
// FOURNISSEUR du formulaire principal.
const addressCategories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([]) const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([]) const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([]) const paymentDelays = ref<RefOption[]>([])
@@ -86,13 +97,10 @@ export function useSupplierReferentials() {
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API. // categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' }) fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code // Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ». // postal du site), ex: 86100 -> « 86 ».
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2), color: s.color, textColor: '#FFFFFF' })) }), .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
fetchAll<ReferentialMember>('/tva_modes') fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays') fetchAll<ReferentialMember>('/payment_delays')
@@ -113,7 +121,6 @@ export function useSupplierReferentials() {
return { return {
categories, categories,
addressCategories,
sites, sites,
tvaModes, tvaModes,
paymentDelays, paymentDelays,
@@ -51,6 +51,5 @@ export interface Supplier {
* `usePaginatedList`. Aucun reset au logout a gerer. * `usePaginatedList`. Aucun reset au logout a gerer.
*/ */
export function useSuppliersRepository() { export function useSuppliersRepository() {
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193). return usePaginatedList<Supplier>({ url: '/suppliers' })
return usePaginatedList<Supplier>({ url: '/suppliers', defaultItemsPerPage: 25 })
} }
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.clients.edit.back')"
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }" v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
@click="goBack" @click="goBack"
/> />
@@ -26,19 +25,17 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:max-tags="3"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -49,7 +46,7 @@
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :empty-option-label="t('commercial.clients.form.main.relationNone')"
:disabled="businessReadonly" :readonly="businessReadonly"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -57,7 +54,7 @@
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="brokerOptions" :options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.broker" :error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
@@ -67,7 +64,7 @@
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="distributorOptions" :options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.distributor" :error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
@@ -77,7 +74,7 @@
v-model="main.triageService" v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
:disabled="businessReadonly" :readonly="businessReadonly"
/> />
</div> </div>
@@ -94,7 +91,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4"> <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/pb-1 alignent le textarea (h-full) en haut ET en bas <!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). --> coussin de chaque cote). -->
@@ -104,24 +101,20 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:disabled="businessReadonly" :readonly="businessReadonly"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -129,30 +122,25 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
:key="revenueAmountKey" v-model="information.revenueAmount"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:mask="PERSON_NAME_MASK"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
</div> </div>
@@ -179,8 +167,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1" :readonly="businessReadonly"
:disabled="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -212,13 +199,12 @@
:key="address.id ?? `new-${index}`" :key="address.id ?? `new-${index}`"
:model-value="address" :model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="addressCategoryOptions" :category-options="addressCategoryOptions"
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -247,23 +233,20 @@
editable uniquement si accounting.manage). --> editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). --> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -271,7 +254,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -279,9 +262,8 @@
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -289,7 +271,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -299,7 +281,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -310,7 +292,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="bankOptions" :options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -319,47 +301,39 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`" :key="rib.id ?? `new-${index}`"
class="pb-[20px]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)" @click="askRemoveRib(index)"
/> />
</div> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -395,7 +369,7 @@
</template> </template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). --> <!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md"> <MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2> <h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
</template> </template>
@@ -421,8 +395,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { CategoryOption, RefOption } from '~/modules/commercial/types/referentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors' import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
canEditClient, canEditClient,
@@ -450,9 +423,6 @@ import {
type InformationFormDraft, type InformationFormDraft,
type MainFormDraft, type MainFormDraft,
} from '~/modules/commercial/utils/forms/clientEdit' } from '~/modules/commercial/utils/forms/clientEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { import {
buildClientFormTabKeys, buildClientFormTabKeys,
isAddressValid, isAddressValid,
@@ -481,6 +451,9 @@ import { readHistoryTab } from '~/shared/utils/historyTab'
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
const EMPLOYEES_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 { t } = useI18n()
const api = useApi() const api = useApi()
const toast = useToast() const toast = useToast()
@@ -518,22 +491,6 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail)) const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail)) const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail)) const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
const contacts = ref<ContactFormDraft[]>([]) const contacts = ref<ContactFormDraft[]>([])
const addresses = ref<AddressFormDraft[]>([]) const addresses = ref<AddressFormDraft[]>([])
const ribs = ref<RibFormDraft[]>([]) const ribs = ref<RibFormDraft[]>([])
@@ -572,17 +529,15 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
return [...primary, ...extra.filter(o => !seen.has(o.value))] return [...primary, ...extra.filter(o => !seen.has(o.value))]
} }
// Categories du formulaire principal (type CLIENT) : referentiel UNION categories const embedCategoryOptions = computed<CategoryOption[]>(() => {
// embarquees du client (fallback si le referentiel n'est pas chargeable). const fromClient = categoryOptionsOf(client.value?.categories)
const embedClientCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(client.value?.categories)) const fromAddresses = (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedClientCategoryOptions.value)) return mergeOptions(fromClient, fromAddresses)
// Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories })
// embarquees des adresses (fallback meme fonction qu'au-dessus). const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
const embedAddressCategoryOptions = computed<CategoryOption[]>(() => // Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
mergeOptions([], (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
)
const addressCategoryOptions = computed(() => const addressCategoryOptions = computed(() =>
mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value), mainCategoryOptions.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
) )
const embedSiteOptions = computed<RefOption[]>(() => const embedSiteOptions = computed<RefOption[]>(() =>
@@ -713,11 +668,6 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
}) })
} }
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Erreurs de validation par champ (ERP-101) ─────────────────────────────── // ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) : // Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour // un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
@@ -817,7 +767,6 @@ function askRemoveContact(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact, makeEmpty: emptyContact,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -895,7 +844,6 @@ function askRemoveAddress(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress, makeEmpty: emptyAddress,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -996,7 +944,6 @@ function askRemoveRib(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib, makeEmpty: emptyRib,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.clients.consultation.back')"
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }" v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
@click="goBack" @click="goBack"
/> />
@@ -24,7 +23,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="danger" variant="secondary"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('commercial.clients.action.archive')" :label="t('commercial.clients.action.archive')"
@@ -49,102 +48,86 @@
<!-- Formulaire principal (lecture seule) --> <!-- Formulaire principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(client.companyName)"
:model-value="client.companyName" :model-value="client.companyName"
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
disabled readonly
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="isFilled(categoryIris)"
:model-value="categoryIris" :model-value="categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:max-tags="3"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
disabled readonly
/> />
<!-- Relation : masquee en consultation si aucune (ERP-193) ; en edition <!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
elle reste toujours visible (vide = « Aucun »). -->
<MalioSelect <MalioSelect
v-if="isFilled(relation.type)"
:model-value="relation.type" :model-value="relation.type"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :empty-option-label="t('commercial.clients.form.main.relationNone')"
disabled readonly
/> />
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant, <!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
aucune valeur sans relation meme comportement qu'en edition). --> aucune valeur sans relation meme comportement qu'en edition). -->
<MalioInputText <MalioInputText
v-if="relation.type && isFilled(relation.name)" v-if="relation.type"
:model-value="relation.name" :model-value="relation.name"
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')" :label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
disabled readonly
/> />
<!-- Service de triage : case a cocher masquee si non cochee (ERP-193). -->
<MalioCheckbox <MalioCheckbox
v-if="isFilled(client.triageService === true)"
:model-value="client.triageService === true" :model-value="client.triageService === true"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
disabled readonly
/> />
</div> </div>
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── --> <!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
<!-- ERP-193 : on n'affiche la barre que s'il reste au moins un onglet <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
non vide (sinon seul le bloc principal est visible). -->
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4"> <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/pb-1 alignent le textarea (h-full) en haut ET en bas <!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). --> coussin de chaque cote). -->
<MalioInputTextArea <MalioInputTextArea
v-if="isFilled(information.description)"
:model-value="information.description" :model-value="information.description"
:label="t('commercial.clients.form.information.description')" :label="t('commercial.clients.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.competitors)"
:model-value="information.competitors" :model-value="information.competitors"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
disabled readonly
/> />
<MalioDate <MalioDate
v-if="isFilled(information.foundedAt)"
:model-value="information.foundedAt" :model-value="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.employeesCount)"
:model-value="information.employeesCount" :model-value="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
disabled readonly
/> />
<MalioInputAmount <MalioInputAmount
v-if="isFilled(information.revenueAmount)"
:model-value="information.revenueAmount" :model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.directorName)"
:model-value="information.directorName" :model-value="information.directorName"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
disabled readonly
/> />
<MalioInputAmount <MalioInputAmount
v-if="isFilled(information.profitAmount)"
:model-value="information.profitAmount" :model-value="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
disabled readonly
/> />
</div> </div>
</template> </template>
@@ -157,9 +140,7 @@
:key="contact.id ?? index" :key="contact.id ?? index"
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1" readonly
disabled
hide-empty
/> />
</div> </div>
</template> </template>
@@ -172,13 +153,11 @@
:key="view.draft.id ?? index" :key="view.draft.id ?? index"
:model-value="view.draft" :model-value="view.draft"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:category-options="view.categoryOptions" :category-options="view.categoryOptions"
:site-options="allSiteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
@@ -186,52 +165,44 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). --> <!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). --> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(accounting.siren)"
:model-value="accounting.siren" :model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(accounting.accountNumber)"
:model-value="accounting.accountNumber" :model-value="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.tvaModeIri)"
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(accounting.nTva)"
:model-value="accounting.nTva" :model-value="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.paymentDelayIri)"
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.paymentTypeIri)"
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="accounting.bankIri" v-if="accounting.bankIri"
@@ -239,51 +210,48 @@
:options="bankOptions" :options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
</div> </div>
</div> </div>
<!-- Blocs RIB (0..n), lecture seule. <!-- Blocs RIB (0..n), lecture seule. -->
Titre « RIB N », filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in ribs" v-for="(rib, index) in ribs"
:key="rib.id ?? index" :key="rib.id ?? index"
class="pb-[20px]" class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
> >
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(rib.label)"
:model-value="rib.label" :model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(rib.bic)"
:model-value="rib.bic" :model-value="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(rib.iban)"
:model-value="rib.iban" :model-value="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
disabled readonly
/> />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
Rapports / Echanges) ne sont plus rendus en consultation <!-- Onglets non encore implementes : frame vide (navigation libre). -->
(masquage des onglets vides) slots supprimes. --> <template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList> </MalioTabList>
</template> </template>
<!-- Modal de confirmation Archiver / Restaurer. --> <!-- Modal de confirmation Archiver / Restaurer. -->
<MalioModal :dismissable="false" v-model="confirmOpen" modal-class="max-w-md"> <MalioModal v-model="confirmOpen" modal-class="max-w-md">
<template #header> <template #header>
<h2 class="text-[24px] font-bold"> <h2 class="text-[24px] font-bold">
{{ isArchived ? t('commercial.clients.consultation.confirmRestore.title') : t('commercial.clients.consultation.confirmArchive.title') }} {{ isArchived ? t('commercial.clients.consultation.confirmRestore.title') : t('commercial.clients.consultation.confirmArchive.title') }}
@@ -310,14 +278,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules' import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf, contactOptionsOf,
mapAccountingDraft, mapAccountingDraft,
mapAddressView, mapAddressView,
@@ -332,7 +299,6 @@ import {
type SelectOption, type SelectOption,
} from '~/modules/commercial/utils/forms/clientConsultation' } from '~/modules/commercial/utils/forms/clientConsultation'
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm' import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur). // Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -446,11 +412,9 @@ const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paym
const bankOptions = computed(() => referentialOptionOf(client.value?.bank)) const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ──── // ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout // 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
// onglet de donnees vide. La liste depend donc du payload charge. // 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const visibleTabKeys = computed(() => clientConsultationVisibleTabs(client.value, { const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
canAccountingView: canAccountingView.value,
}))
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline', information: 'mdi:account-outline',
@@ -463,26 +427,14 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:account-group-outline', exchanges: 'mdi:account-group-outline',
} }
const tabs = computed(() => visibleTabKeys.value.map(key => ({ const tabs = computed(() => tabKeys.value.map(key => ({
key, key,
label: t(`commercial.clients.tab.${key}`), label: t(`commercial.clients.tab.${key}`),
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : vide tant que le client n'est pas charge. Des que la liste // Onglet initial : repris de l'edition au retour (history.state), sinon Information.
// des onglets visibles est connue, on cale sur l'onglet repris de l'edition const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// (history.state) s'il est encore visible, sinon le premier onglet visible.
// Un watcher recale aussi si l'onglet courant disparait (ex: changement de droit).
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = readHistoryTab(keys) ?? keys[0]
}
}, { immediate: true })
// ── Navigation ───────────────────────────────────────────────────────────── // ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void { function goBack(): void {
@@ -43,7 +43,7 @@
@update:page="goToPage" @update:page="goToPage"
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<!-- Categories : libelles (name) separes par une virgule (ERP-193). --> <!-- Categories : codes stables separes par une virgule (ERP-78). -->
<template #cell-categories="{ item }"> <template #cell-categories="{ item }">
{{ formatCategories(item) }} {{ formatCategories(item) }}
</template> </template>
@@ -62,9 +62,10 @@
</span> </span>
</template> </template>
<!-- Derniere activite : volontairement vide tant que le suivi <!-- Derniere activite : date de derniere modification (updatedAt). -->
d'activite (onglets de la fiche) n'est pas encore developpe. --> <template #cell-lastActivity="{ item }">
<template #cell-lastActivity /> {{ formatLastActivity(item) }}
</template>
</MalioDataTable> </MalioDataTable>
<div class="flex justify-center mt-4"> <div class="flex justify-center mt-4">
@@ -198,6 +199,7 @@ const rows = computed(() => clients.value.map(client => ({
companyName: client.companyName, companyName: client.companyName,
categories: client.categories, categories: client.categories,
sites: client.sites, sites: client.sites,
updatedAt: client.updatedAt,
}))) })))
const columns = [ const columns = [
@@ -207,10 +209,30 @@ const columns = [
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') }, { key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
] ]
/** Libelles (name) des categories du client, separes par une virgule (ERP-193). */ /** Codes des categories du client, separes par une virgule (ERP-78). */
function formatCategories(item: Record<string, unknown>): string { function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Client['categories']) ?? [] const categories = (item.categories as Client['categories']) ?? []
return categories.map(c => c.name).join(', ') 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}). */ /** Clic sur une ligne → ecran Consultation (route a plat /clients/{id}). */
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.clients.form.back')"
v-bind="{ ariaLabel: t('commercial.clients.form.back') }" v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
@click="goBack" @click="goBack"
/> />
@@ -20,19 +19,17 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:disabled="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="referentials.categories.value" :options="referentials.categories.value"
:max-tags="3"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -43,7 +40,7 @@
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :empty-option-label="t('commercial.clients.form.main.relationNone')"
:disabled="mainLocked" :readonly="mainLocked"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -51,7 +48,7 @@
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="referentials.brokers.value" :options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.broker" :error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
@@ -61,7 +58,7 @@
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="referentials.distributors.value" :options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.distributor" :error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
@@ -71,7 +68,7 @@
v-model="main.triageService" v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
:disabled="mainLocked" :readonly="mainLocked"
/> />
</div> </div>
@@ -88,7 +85,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4"> <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/pb-1 alignent le textarea (h-full) sur les inputs, dont <!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont
le champ de 40px est centre dans un conteneur h-12 (~4px de le champ de 40px est centre dans un conteneur h-12 (~4px de
coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px
@@ -99,24 +96,20 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -124,42 +117,37 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
:key="revenueAmountKey" v-model="information.revenueAmount"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:mask="PERSON_NAME_MASK"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
</div> </div>
<!-- Masque tant que le client n'est pas cree : Information etant <div v-if="!isValidated('information')" class="mt-12 flex justify-center">
l'onglet actif par defaut, son Valider ne doit pas apparaitre a <!-- Desactive tant que le client n'est pas cree (evite un PATCH
cote de celui du formulaire principal (ERP-193). Onglet facultatif : avant le POST si clic trop tot, Information etant l'onglet
un enregistrement a vide reste possible, c'est le back qui valide. --> actif par defaut). Onglet facultatif : un enregistrement a
<div v-if="!isValidated('information') && clientId !== null" class="mt-12 flex justify-center"> vide reste possible, c'est le back qui valide. -->
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting" :disabled="tabSubmitting || clientId === null"
@click="submitInformation" @click="submitInformation"
/> />
</div> </div>
@@ -178,8 +166,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1" :readonly="isValidated('contact')"
:disabled="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -211,13 +198,12 @@
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="addressCategoryOptions" :category-options="addressCategoryOptions"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -245,23 +231,20 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) --> <!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). --> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -269,7 +252,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -277,9 +260,8 @@
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -287,7 +269,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -297,7 +279,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -308,7 +290,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -317,48 +299,40 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). -->
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :key="index"
class="pb-[20px]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)" @click="askRemoveRib(index)"
/> />
</div> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -392,7 +366,7 @@
</MalioTabList> </MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). --> <!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md"> <MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2> <h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
</template> </template>
@@ -417,8 +391,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { RefOption } from '~/modules/commercial/types/referentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors' import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
buildClientFormTabKeys, buildClientFormTabKeys,
@@ -434,9 +407,6 @@ import {
lastFillableTabKey, lastFillableTabKey,
showsRelationAndTriageFields, showsRelationAndTriageFields,
} from '~/modules/commercial/utils/forms/clientFormRules' } from '~/modules/commercial/utils/forms/clientFormRules'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { import {
buildAddressPayload, buildAddressPayload,
buildMainPayload, buildMainPayload,
@@ -458,6 +428,9 @@ const SIREN_MASK = '#########'
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7). // Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
const EMPLOYEES_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 { t } = useI18n()
const api = useApi() const api = useApi()
const toast = useToast() const toast = useToast()
@@ -692,22 +665,6 @@ const information = reactive({
directorName: null as string | null, directorName: null as string | null,
}) })
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */ /** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value) return
@@ -815,8 +772,10 @@ async function submitContacts(): Promise<void> {
const addresses = ref<AddressFormDraft[]>([emptyAddress()]) const addresses = ref<AddressFormDraft[]>([emptyAddress()])
const addressDegradedNotified = ref(false) const addressDegradedNotified = ref(false)
// Categories autorisees sur une adresse : taxonomie dediee type ADRESSE. // Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
const addressCategoryOptions = computed(() => referentials.addressCategories.value) 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). // Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
const contactOptions = computed<RefOption[]>(() => const contactOptions = computed<RefOption[]>(() =>
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.suppliers.edit.back')"
v-bind="{ ariaLabel: t('commercial.suppliers.edit.back') }" v-bind="{ ariaLabel: t('commercial.suppliers.edit.back') }"
@click="goBack" @click="goBack"
/> />
@@ -27,17 +26,15 @@
v-model="main.companyName" v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')" :label="t('commercial.suppliers.form.main.companyName')"
:required="true" :required="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
:mask="FREE_TEXT_MASK"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:max-tags="3"
:label="t('commercial.suppliers.form.main.categories')" :label="t('commercial.suppliers.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -57,7 +54,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4"> <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/pb-1 alignent le textarea (h-full) sur les inputs. --> <!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs. -->
<MalioInputTextArea <MalioInputTextArea
v-model="information.description" v-model="information.description"
@@ -65,24 +62,20 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')" :label="t('commercial.suppliers.form.information.competitors')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
:mask="FREE_TEXT_MASK"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')" :label="t('commercial.suppliers.form.information.foundedAt')"
:disabled="businessReadonly" :readonly="businessReadonly"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -90,30 +83,25 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')" :label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
:key="revenueAmountKey" v-model="information.revenueAmount"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')" :label="t('commercial.suppliers.form.information.revenueAmount')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')" :label="t('commercial.suppliers.form.information.directorName')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
:mask="PERSON_NAME_MASK"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')" :label="t('commercial.suppliers.form.information.profitAmount')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
<!-- Volume previsionnel : specifique fournisseur (entier). --> <!-- Volume previsionnel : specifique fournisseur (entier). -->
@@ -121,7 +109,7 @@
v-model="information.volumeForecast" v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')" :label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK" :mask="VOLUME_FORECAST_MASK"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.volumeForecast" :error="informationErrors.errors.volumeForecast"
/> />
</div> </div>
@@ -148,8 +136,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1" :readonly="businessReadonly"
:disabled="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -181,13 +168,12 @@
:key="address.id ?? `new-${index}`" :key="address.id ?? `new-${index}`"
:model-value="address" :model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1" :category-options="mainCategoryOptions"
:category-options="addressCategoryOptions"
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -216,31 +202,28 @@
editable uniquement si accounting.manage). --> editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). --> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')" :label="t('commercial.suppliers.form.accounting.accountNumber')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')" :label="t('commercial.suppliers.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -249,16 +232,15 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')" :label="t('commercial.suppliers.form.accounting.nTva')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')" :label="t('commercial.suppliers.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -268,7 +250,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')" :label="t('commercial.suppliers.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -279,7 +261,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="bankOptions" :options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')" :label="t('commercial.suppliers.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -288,49 +270,41 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`" :key="rib.id ?? `new-${index}`"
class="pb-[20px]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }" v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)" @click="askRemoveRib(index)"
/> />
</div> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')" :label="t('commercial.suppliers.form.accounting.ribBic')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
:mask="CODE_ALNUM_MASK"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')" :label="t('commercial.suppliers.form.accounting.ribIban')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
:mask="CODE_ALNUM_MASK"
/> />
</div> </div>
</div> </div>
@@ -364,7 +338,7 @@
</template> </template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). --> <!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md"> <MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.suppliers.form.confirmDelete.title') }}</h2> <h2 class="text-[24px] font-bold">{{ t('commercial.suppliers.form.confirmDelete.title') }}</h2>
</template> </template>
@@ -390,8 +364,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier' import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { useSupplierReferentials } from '~/modules/commercial/composables/useSupplierReferentials' import { useSupplierReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import type { CategoryOption, RefOption } from '~/modules/commercial/types/referentials'
import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors' import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors'
import { import {
canEditSupplier, canEditSupplier,
@@ -419,8 +392,6 @@ import {
type MainFormDraft, type MainFormDraft,
type SupplierEditAbilities, type SupplierEditAbilities,
} from '~/modules/commercial/utils/forms/supplierEdit' } from '~/modules/commercial/utils/forms/supplierEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { import {
buildSupplierFormTabKeys, buildSupplierFormTabKeys,
isAddressValid, isAddressValid,
@@ -441,7 +412,6 @@ import {
} from '~/modules/commercial/types/supplierForm' } from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow' import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
@@ -487,22 +457,6 @@ const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail)) const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail)) const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail)) const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail))
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
const contacts = ref<SupplierContactFormDraft[]>([]) const contacts = ref<SupplierContactFormDraft[]>([])
const addresses = ref<SupplierAddressFormDraft[]>([]) const addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([]) const ribs = ref<SupplierRibFormDraft[]>([])
@@ -538,18 +492,15 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
return [...primary, ...extra.filter(o => !seen.has(o.value))] return [...primary, ...extra.filter(o => !seen.has(o.value))]
} }
// Categories du formulaire principal (type FOURNISSEUR) : referentiel UNION // Categories issues de l'embed (fournisseur + adresses), role-independantes.
// categories embarquees du fournisseur (fallback si referentiel non chargeable). const embedCategoryOptions = computed<CategoryOption[]>(() => {
const embedSupplierCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(supplier.value?.categories)) const fromSupplier = categoryOptionsOf(supplier.value?.categories)
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedSupplierCategoryOptions.value)) const fromAddresses = (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
// Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories return mergeOptions(fromSupplier, fromAddresses)
// embarquees des adresses (meme logique de fallback). })
const embedAddressCategoryOptions = computed<CategoryOption[]>(() => // Toutes les categories de type FOURNISSEUR sont autorisees, sur le bloc principal
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))), // comme sur une adresse (pas de restriction Distributeur/Courtier comme au M1 — RG-2.10).
) const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
const addressCategoryOptions = computed(() =>
mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
)
const embedSiteOptions = computed<RefOption[]>(() => const embedSiteOptions = computed<RefOption[]>(() =>
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))), mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
@@ -632,11 +583,6 @@ function showError(e: unknown): void {
toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) }) toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) })
} }
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Erreurs de validation par champ (ERP-101) ─────────────────────────────── // ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
const { const {
mainErrors, mainErrors,
@@ -720,7 +666,6 @@ function askRemoveContact(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact, makeEmpty: emptyContact,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -789,7 +734,6 @@ function askRemoveAddress(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress, makeEmpty: emptyAddress,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -889,7 +833,6 @@ function askRemoveRib(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib, makeEmpty: emptyRib,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.suppliers.consultation.back')"
v-bind="{ ariaLabel: t('commercial.suppliers.consultation.back') }" v-bind="{ ariaLabel: t('commercial.suppliers.consultation.back') }"
@click="goBack" @click="goBack"
/> />
@@ -24,7 +23,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="danger" variant="secondary"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('commercial.suppliers.action.archive')" :label="t('commercial.suppliers.action.archive')"
@@ -49,83 +48,69 @@
<!-- Formulaire principal (lecture seule) --> <!-- Formulaire principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(supplier.companyName)"
:model-value="supplier.companyName" :model-value="supplier.companyName"
:label="t('commercial.suppliers.form.main.companyName')" :label="t('commercial.suppliers.form.main.companyName')"
disabled readonly
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="isFilled(categoryIris)"
:model-value="categoryIris" :model-value="categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:max-tags="3"
:label="t('commercial.suppliers.form.main.categories')" :label="t('commercial.suppliers.form.main.categories')"
:display-tag="true" :display-tag="true"
disabled readonly
/> />
</div> </div>
<!-- Onglets (navigation libre, tout en lecture seule) --> <!-- Onglets (navigation libre, tout en lecture seule) -->
<!-- Masque la barre d'onglets (et sa bordure) quand aucun onglet n'est <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
visible : seul le formulaire principal est rempli (aligné sur le
client). -->
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4"> <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/pb-1 alignent le textarea (h-full) en haut ET en bas <!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12). --> sur les inputs (champ 40px centre dans un h-12). -->
<MalioInputTextArea <MalioInputTextArea
v-if="isFilled(information.description)"
:model-value="information.description" :model-value="information.description"
:label="t('commercial.suppliers.form.information.description')" :label="t('commercial.suppliers.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.competitors)"
:model-value="information.competitors" :model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')" :label="t('commercial.suppliers.form.information.competitors')"
disabled readonly
/> />
<MalioDate <MalioDate
v-if="isFilled(information.foundedAt)"
:model-value="information.foundedAt" :model-value="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')" :label="t('commercial.suppliers.form.information.foundedAt')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.employeesCount)"
:model-value="information.employeesCount" :model-value="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')" :label="t('commercial.suppliers.form.information.employeesCount')"
disabled readonly
/> />
<MalioInputAmount <MalioInputAmount
v-if="isFilled(information.revenueAmount)"
:model-value="information.revenueAmount" :model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')" :label="t('commercial.suppliers.form.information.revenueAmount')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(information.directorName)"
:model-value="information.directorName" :model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')" :label="t('commercial.suppliers.form.information.directorName')"
disabled readonly
/> />
<MalioInputAmount <MalioInputAmount
v-if="isFilled(information.profitAmount)"
:model-value="information.profitAmount" :model-value="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')" :label="t('commercial.suppliers.form.information.profitAmount')"
disabled readonly
/> />
<!-- Volume previsionnel : specifique fournisseur (entier). --> <!-- Volume previsionnel : specifique fournisseur (entier). -->
<MalioInputText <MalioInputText
v-if="isFilled(information.volumeForecast)"
:model-value="information.volumeForecast" :model-value="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')" :label="t('commercial.suppliers.form.information.volumeForecast')"
disabled readonly
/> />
</div> </div>
</template> </template>
@@ -138,9 +123,7 @@
:key="contact.id ?? index" :key="contact.id ?? index"
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1" readonly
disabled
hide-empty
/> />
</div> </div>
</template> </template>
@@ -153,13 +136,11 @@
:key="view.draft.id ?? index" :key="view.draft.id ?? index"
:model-value="view.draft" :model-value="view.draft"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:category-options="view.categoryOptions" :category-options="view.categoryOptions"
:site-options="allSiteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
@@ -167,52 +148,44 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). --> <!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). --> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(accounting.siren)"
:model-value="accounting.siren" :model-value="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(accounting.accountNumber)"
:model-value="accounting.accountNumber" :model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')" :label="t('commercial.suppliers.form.accounting.accountNumber')"
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.tvaModeIri)"
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')" :label="t('commercial.suppliers.form.accounting.tvaMode')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(accounting.nTva)"
:model-value="accounting.nTva" :model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')" :label="t('commercial.suppliers.form.accounting.nTva')"
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.paymentDelayIri)"
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')" :label="t('commercial.suppliers.form.accounting.paymentDelay')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="isFilled(accounting.paymentTypeIri)"
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')" :label="t('commercial.suppliers.form.accounting.paymentType')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="accounting.bankIri" v-if="accounting.bankIri"
@@ -220,51 +193,48 @@
:options="bankOptions" :options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')" :label="t('commercial.suppliers.form.accounting.bank')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
</div> </div>
</div> </div>
<!-- Blocs RIB (0..n), lecture seule. <!-- Blocs RIB (0..n), lecture seule. -->
Titre « RIB N », filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in ribs" v-for="(rib, index) in ribs"
:key="rib.id ?? index" :key="rib.id ?? index"
class="pb-[20px]" class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
> >
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(rib.label)"
:model-value="rib.label" :model-value="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(rib.bic)"
:model-value="rib.bic" :model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')" :label="t('commercial.suppliers.form.accounting.ribBic')"
disabled readonly
/> />
<MalioInputText <MalioInputText
v-if="isFilled(rib.iban)"
:model-value="rib.iban" :model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')" :label="t('commercial.suppliers.form.accounting.ribIban')"
disabled readonly
/> />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
Rapports / Echanges) ne sont plus rendus en consultation <!-- Onglets non encore implementes : frame vide (navigation libre). -->
(masquage des onglets vides) — slots supprimes. --> <template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList> </MalioTabList>
</template> </template>
<!-- Modal de confirmation Archiver / Restaurer. --> <!-- Modal de confirmation Archiver / Restaurer. -->
<MalioModal :dismissable="false" v-model="confirmOpen" modal-class="max-w-md"> <MalioModal v-model="confirmOpen" modal-class="max-w-md">
<template #header> <template #header>
<h2 class="text-[24px] font-bold"> <h2 class="text-[24px] font-bold">
{{ isArchived ? t('commercial.suppliers.consultation.confirmRestore.title') : t('commercial.suppliers.consultation.confirmArchive.title') }} {{ isArchived ? t('commercial.suppliers.consultation.confirmRestore.title') : t('commercial.suppliers.consultation.confirmArchive.title') }}
@@ -291,9 +261,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier' import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules' import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditSupplier, canEditSupplier,
@@ -308,12 +278,10 @@ import {
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
supplierConsultationVisibleTabs,
type SelectOption, type SelectOption,
type SupplierDetail, type SupplierDetail,
} from '~/modules/commercial/utils/forms/supplierConsultation' } from '~/modules/commercial/utils/forms/supplierConsultation'
import { emptyContact } from '~/modules/commercial/types/supplierForm' import { emptyContact } from '~/modules/commercial/types/supplierForm'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur). // Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -419,11 +387,9 @@ const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.pa
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank)) const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ──── // ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout // 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
// onglet de donnees vide. La liste depend donc du payload charge. // 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const visibleTabKeys = computed(() => supplierConsultationVisibleTabs(supplier.value, { const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
canAccountingView: canAccountingView.value,
}))
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline', information: 'mdi:account-outline',
@@ -436,25 +402,14 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:account-group-outline', exchanges: 'mdi:account-group-outline',
} }
const tabs = computed(() => visibleTabKeys.value.map(key => ({ const tabs = computed(() => tabKeys.value.map(key => ({
key, key,
label: t(`commercial.suppliers.tab.${key}`), label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : vide tant que le fournisseur n'est pas charge. Des que la // Onglet initial : repris de l'edition au retour (history.state), sinon Information.
// liste des onglets visibles est connue, on cale sur l'onglet repris de const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// l'edition (history.state) s'il est encore visible, sinon le premier visible.
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = readHistoryTab(keys) ?? keys[0]
}
}, { immediate: true })
// ── Navigation ───────────────────────────────────────────────────────────── // ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void { function goBack(): void {
@@ -43,7 +43,7 @@
@update:page="goToPage" @update:page="goToPage"
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). --> <!-- Categories : libelles (name) separes par une virgule (spec M2). -->
<template #cell-categories="{ item }"> <template #cell-categories="{ item }">
{{ formatCategories(item) }} {{ formatCategories(item) }}
</template> </template>
@@ -62,9 +62,10 @@
</span> </span>
</template> </template>
<!-- Derniere activite : volontairement vide tant que le suivi <!-- Derniere activite : date de derniere modification (updatedAt). -->
d'activite (onglets de la fiche) n'est pas encore developpe. --> <template #cell-lastActivity="{ item }">
<template #cell-lastActivity /> {{ formatLastActivity(item) }}
</template>
</MalioDataTable> </MalioDataTable>
<div class="flex justify-center mt-4"> <div class="flex justify-center mt-4">
@@ -198,6 +199,7 @@ const rows = computed(() => suppliers.value.map(supplier => ({
companyName: supplier.companyName, companyName: supplier.companyName,
categories: supplier.categories, categories: supplier.categories,
sites: supplier.sites, sites: supplier.sites,
updatedAt: supplier.updatedAt,
}))) })))
const columns = [ const columns = [
@@ -207,12 +209,32 @@ const columns = [
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') }, { key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
] ]
/** Libelles (name) des categories du fournisseur, separes par une virgule (aligne sur le client, ERP-193). */ /** Libelles des categories du fournisseur, separes par une virgule (spec M2 : name). */
function formatCategories(item: Record<string, unknown>): string { function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Supplier['categories']) ?? [] const categories = (item.categories as Supplier['categories']) ?? []
return categories.map(c => c.name).join(', ') return categories.map(c => c.name).join(', ')
} }
/**
* Derniere activite : faute de suivi d'activite metier au M2, 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 /suppliers/{id}). */ /** Clic sur une ligne → ecran Consultation (route a plat /suppliers/{id}). */
function onRowClick(item: Record<string, unknown>): void { function onRowClick(item: Record<string, unknown>): void {
router.push(`/suppliers/${item.id}`) router.push(`/suppliers/${item.id}`)
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('commercial.suppliers.form.back')"
v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }" v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }"
@click="goBack" @click="goBack"
/> />
@@ -22,17 +21,15 @@
v-model="main.companyName" v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')" :label="t('commercial.suppliers.form.main.companyName')"
:required="true" :required="true"
:disabled="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
:mask="FREE_TEXT_MASK"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="referentials.categories.value" :options="referentials.categories.value"
:max-tags="3"
:label="t('commercial.suppliers.form.main.categories')" :label="t('commercial.suppliers.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -52,31 +49,27 @@
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4"> <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 <MalioInputTextArea
v-model="information.description" v-model="information.description"
:label="t('commercial.suppliers.form.information.description')" :label="t('commercial.suppliers.form.information.description')"
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')" :label="t('commercial.suppliers.form.information.competitors')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
:mask="FREE_TEXT_MASK"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')" :label="t('commercial.suppliers.form.information.foundedAt')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -84,30 +77,25 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')" :label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
:key="revenueAmountKey" v-model="information.revenueAmount"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')" :label="t('commercial.suppliers.form.information.revenueAmount')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')" :label="t('commercial.suppliers.form.information.directorName')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
:mask="PERSON_NAME_MASK"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')" :label="t('commercial.suppliers.form.information.profitAmount')"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
<!-- Volume previsionnel : specifique fournisseur. Champ texte <!-- Volume previsionnel : specifique fournisseur. Champ texte
@@ -116,18 +104,15 @@
v-model="information.volumeForecast" v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')" :label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK" :mask="VOLUME_FORECAST_MASK"
:disabled="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.volumeForecast" :error="informationErrors.errors.volumeForecast"
/> />
</div> </div>
<!-- Masque tant que le fournisseur n'est pas cree : Information etant <div v-if="!isValidated('information')" class="mt-12 flex justify-center">
l'onglet actif par defaut, son Valider ne doit pas apparaitre a cote
de celui du formulaire principal (ERP-193). -->
<div v-if="!isValidated('information') && supplierId !== null" class="mt-12 flex justify-center">
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.suppliers.form.submit')" :label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting" :disabled="tabSubmitting || supplierId === null"
@click="submitInformation" @click="submitInformation"
/> />
</div> </div>
@@ -146,8 +131,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1" :readonly="isValidated('contacts')"
:disabled="isValidated('contacts')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -179,13 +163,12 @@
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1" :category-options="referentials.categories.value"
:category-options="referentials.addressCategories.value"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="isValidated('addresses')" :readonly="isValidated('addresses')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -213,31 +196,28 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) --> <!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). --> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')" :label="t('commercial.suppliers.form.accounting.accountNumber')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('commercial.suppliers.form.accounting.tvaMode')" :label="t('commercial.suppliers.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -246,16 +226,15 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')" :label="t('commercial.suppliers.form.accounting.nTva')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('commercial.suppliers.form.accounting.paymentDelay')" :label="t('commercial.suppliers.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -265,7 +244,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('commercial.suppliers.form.accounting.paymentType')" :label="t('commercial.suppliers.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -276,7 +255,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('commercial.suppliers.form.accounting.bank')" :label="t('commercial.suppliers.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -285,49 +264,41 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). -->
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :key="index"
class="pb-[20px]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }" v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)" @click="askRemoveRib(index)"
/> />
</div> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')" :label="t('commercial.suppliers.form.accounting.ribBic')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
:mask="CODE_ALNUM_MASK"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')" :label="t('commercial.suppliers.form.accounting.ribIban')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
:mask="CODE_ALNUM_MASK"
/> />
</div> </div>
</div> </div>
@@ -357,7 +328,7 @@
</MalioTabList> </MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). --> <!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md"> <MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.suppliers.form.confirmDelete.title') }}</h2> <h2 class="text-[24px] font-bold">{{ t('commercial.suppliers.form.confirmDelete.title') }}</h2>
</template> </template>
@@ -382,8 +353,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useSupplierReferentials } from '~/modules/commercial/composables/useSupplierReferentials' import { useSupplierReferentials, type RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import type { RefOption } from '~/modules/commercial/types/referentials'
import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors' import { useSupplierFormErrors } from '~/modules/commercial/composables/useSupplierFormErrors'
import { import {
buildSupplierFormTabKeys, buildSupplierFormTabKeys,
@@ -405,8 +375,6 @@ import {
buildMainPayload, buildMainPayload,
buildRibPayload, buildRibPayload,
} from '~/modules/commercial/utils/forms/supplierEdit' } from '~/modules/commercial/utils/forms/supplierEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { import {
emptyAddress, emptyAddress,
emptyContact, emptyContact,
@@ -417,7 +385,6 @@ import {
} from '~/modules/commercial/types/supplierForm' } from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow' import { isRowRemovable } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -597,22 +564,6 @@ const information = reactive({
volumeForecast: null as string | null, volumeForecast: null as string | null,
}) })
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */ /** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return if (supplierId.value === null || tabSubmitting.value) return
@@ -1,37 +0,0 @@
/**
* Types d'options des referentiels (selects) partages entre les ecrans Client (M1)
* et Fournisseur (M2).
*
* Centralises ici pour eviter la double declaration dans `useClientReferentials`
* et `useSupplierReferentials` : Nuxt auto-importe les symboles exportes par
* `composables/*`, et deux composables exportant les memes noms (`PaymentTypeOption`,
* `CategoryOption`...) provoquent un warning « Duplicated imports » au build.
* Le dossier `types/` n'est pas auto-importe : une seule source de verite, importee
* explicitement la ou c'est necessaire.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox ({ label, value }). */
export interface RefOption {
value: string
label: string
// Couleur de fond optionnelle de l'option (hex #RRGGBB). Alimentee par le
// referentiel sites (couleur d'identification du site, affichee sur les tags
// selectionnes du multiselect).
color?: string
// Couleur de texte optionnelle (hex). Sites : blanc, pour rester lisible
// sur le fond colore du tag.
textColor?: string
}
/** Option de type de reglement enrichie de son code stable (RG-1.12/1.13, RG-2.07/2.08). */
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
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest'
import { clampRevenueAmount, REVENUE_AMOUNT_MAX } from '../amountInput'
describe('clampRevenueAmount', () => {
it('laisse les valeurs vides / nulles telles quelles', () => {
expect(clampRevenueAmount(null)).toBeNull()
expect(clampRevenueAmount(undefined)).toBeUndefined()
expect(clampRevenueAmount('')).toBe('')
})
it('laisse une valeur sous le plafond inchangee', () => {
expect(clampRevenueAmount('1000.50')).toBe('1000.50')
expect(clampRevenueAmount('999999999999.99')).toBe('999999999999.99')
})
it('plafonne une valeur au-dessus du maximum', () => {
expect(clampRevenueAmount('1000000000000')).toBe('999999999999.99')
expect(clampRevenueAmount('999999999999999.99')).toBe('999999999999.99')
})
it('tolere une saisie a virgule / avec espaces (securite)', () => {
expect(clampRevenueAmount('1 000 000 000 000,00')).toBe('999999999999.99')
expect(clampRevenueAmount('12,5')).toBe('12,5')
})
it('ne touche pas une saisie non numerique', () => {
expect(clampRevenueAmount('abc')).toBe('abc')
})
it('expose le plafond metier', () => {
expect(REVENUE_AMOUNT_MAX).toBe(999_999_999_999.99)
})
})
@@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf, contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf, iriOf,
mapAccountingDraft, mapAccountingDraft,
mapAddressToDraft, mapAddressToDraft,
@@ -168,9 +165,9 @@ describe('options construites depuis l\'embed (role-independantes)', () => {
]) ])
}) })
it('siteOptionsOf expose value=IRI, label=nom, color, textColor', () => { it('siteOptionsOf expose value=IRI, label=nom', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([ expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/4', label: 'Chatellerault', color: '#000', textColor: '#FFFFFF' }, { value: '/api/sites/4', label: 'Chatellerault' },
]) ])
}) })
@@ -201,7 +198,7 @@ describe('options construites depuis l\'embed (role-independantes)', () => {
categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }], categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }],
}) })
expect(view.draft.id).toBe(18) expect(view.draft.id).toBe(18)
expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault', textColor: '#FFFFFF' }]) expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }]) expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }])
}) })
}) })
@@ -251,73 +248,3 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
expect(paymentTypeCodeOf(undefined)).toBeNull() expect(paymentTypeCodeOf(undefined)).toBeNull()
}) })
}) })
describe('hasInformationData', () => {
it('faux si tous les champs Information sont vides/absents', () => {
expect(hasInformationData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ Information porte une donnee', () => {
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, directorName: 'Dupont' })).toBe(true)
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, employeesCount: 0 })).toBe(true)
})
})
describe('hasAccountingData', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable scalaire', () => {
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1, siren: '123456789' })).toBe(true)
})
it('vrai avec une relation comptable embarquee (paymentType)', () => {
expect(hasAccountingData({
'@id': '/api/clients/1', id: 1,
paymentType: { '@id': '/api/payment_types/1', code: 'LCR' },
})).toBe(true)
})
it('vrai avec au moins un RIB', () => {
expect(hasAccountingData({
'@id': '/api/clients/1', id: 1,
ribs: [{ '@id': '/api/ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('clientConsultationVisibleTabs', () => {
it('retourne [] tant que le client n\'est pas charge', () => {
expect(clientConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
expect(clientConsultationVisibleTabs(undefined, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles et les onglets vides (client minimal)', () => {
const client: ClientDetail = { '@id': '/api/clients/1', id: 1, companyName: 'ACME' }
expect(clientConsultationVisibleTabs(client, { canAccountingView: true })).toEqual([])
})
it('affiche les onglets non vides dans l\'ordre information/contact/address/accounting', () => {
const client: ClientDetail = {
'@id': '/api/clients/1', id: 1,
directorName: 'Dupont',
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/client_addresses/1', id: 1 }],
siren: '123456789',
}
expect(clientConsultationVisibleTabs(client, { canAccountingView: true }))
.toEqual(['information', 'contact', 'address', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view meme si des donnees existent', () => {
const client: ClientDetail = {
'@id': '/api/clients/1', id: 1,
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
siren: '123456789',
}
expect(clientConsultationVisibleTabs(client, { canAccountingView: false }))
.toEqual(['contact'])
})
})
@@ -3,8 +3,6 @@ import {
canEditSupplier, canEditSupplier,
categoryOptionsOf, categoryOptionsOf,
contactOptionsOf, contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf, iriOf,
mapAccountingDraft, mapAccountingDraft,
mapAddressToDraft, mapAddressToDraft,
@@ -16,7 +14,6 @@ import {
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
siteOptionsOf, siteOptionsOf,
supplierConsultationVisibleTabs,
type SupplierDetail, type SupplierDetail,
} from '../supplierConsultation' } from '../supplierConsultation'
@@ -155,9 +152,9 @@ describe('options construites depuis l\'embed (role-independantes)', () => {
]) ])
}) })
it('siteOptionsOf expose value=IRI, label=nom, color, textColor', () => { it('siteOptionsOf expose value=IRI, label=nom', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#000' }])).toEqual([ expect(siteOptionsOf([{ '@id': '/api/sites/87', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/87', label: 'Chatellerault', color: '#000', textColor: '#FFFFFF' }, { value: '/api/sites/87', label: 'Chatellerault' },
]) ])
}) })
@@ -190,7 +187,7 @@ describe('options construites depuis l\'embed (role-independantes)', () => {
}) })
expect(view.draft.id).toBe(33) expect(view.draft.id).toBe(33)
expect(view.draft.addressType).toBe('RENDU') expect(view.draft.addressType).toBe('RENDU')
expect(view.siteOptions).toEqual([{ value: '/api/sites/87', label: 'Chatellerault', textColor: '#FFFFFF' }]) expect(view.siteOptions).toEqual([{ value: '/api/sites/87', label: 'Chatellerault' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' }]) expect(view.categoryOptions).toEqual([{ value: '/api/categories/2279', label: 'Negociant', code: 'NEGOCIANT' }])
}) })
}) })
@@ -240,60 +237,3 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
expect(paymentTypeCodeOf(undefined)).toBeNull() expect(paymentTypeCodeOf(undefined)).toBeNull()
}) })
}) })
describe('hasInformationData (fournisseur)', () => {
it('faux si tous les champs Information (volume previsionnel inclus) sont vides', () => {
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
})
it('vrai des qu\'un champ Information porte une donnee', () => {
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, directorName: 'Martin' })).toBe(true)
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, volumeForecast: 1200 })).toBe(true)
})
})
describe('hasAccountingData (fournisseur)', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable ou un RIB', () => {
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1, siren: '123456789' })).toBe(true)
expect(hasAccountingData({
'@id': '/api/suppliers/1', id: 1,
ribs: [{ '@id': '/api/supplier_ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('supplierConsultationVisibleTabs', () => {
it('retourne [] tant que le fournisseur n\'est pas charge', () => {
expect(supplierConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles et les onglets vides (fournisseur minimal)', () => {
expect(supplierConsultationVisibleTabs(
{ '@id': '/api/suppliers/1', id: 1, companyName: 'ACME' },
{ canAccountingView: true },
)).toEqual([])
})
it('affiche information/contacts/addresses/accounting (cles plurielles) dans l\'ordre', () => {
const supplier: SupplierDetail = {
'@id': '/api/suppliers/1', id: 1,
volumeForecast: 1000,
contacts: [{ '@id': '/api/supplier_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/supplier_addresses/1', id: 1 }],
siren: '123456789',
}
expect(supplierConsultationVisibleTabs(supplier, { canAccountingView: true }))
.toEqual(['information', 'contacts', 'addresses', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view', () => {
expect(supplierConsultationVisibleTabs(
{ '@id': '/api/suppliers/1', id: 1, siren: '123456789' },
{ canAccountingView: false },
)).toEqual([])
})
})
@@ -1,29 +0,0 @@
/**
* Helpers de saisie des montants des formulaires Client / Fournisseur (commercial).
* Purs / testables. Pendant FRONT de la contrainte back `LessThanOrEqual` posee sur
* `revenueAmount` (Client/Supplier) — retour metier ERP-193 : le chiffre d'affaires
* est plafonne a 999 999 999 999,99.
*/
/** Plafond metier du chiffre d'affaires (CA) : 999 999 999 999,99. */
export const REVENUE_AMOUNT_MAX = 999_999_999_999.99
/** Valeur « modele » (decimale a point, sans separateur) renvoyee quand on plafonne. */
const REVENUE_AMOUNT_MAX_MODEL = '999999999999.99'
/**
* Plafonne le CA au maximum metier. Recoit le modele emis par `MalioInputAmount`
* (chaine propre a decimale `.`, sans espaces) ; tolere malgre tout une virgule /
* des espaces par securite. Renvoie la valeur telle quelle si elle est vide, non
* numerique ou sous le plafond ; sinon la valeur plafonnee.
*/
export function clampRevenueAmount(value: string | null | undefined): string | null | undefined {
if (value === null || value === undefined || value === '') {
return value
}
const n = Number(String(value).replace(/\s/g, '').replace(',', '.'))
if (Number.isNaN(n)) {
return value
}
return n > REVENUE_AMOUNT_MAX ? REVENUE_AMOUNT_MAX_MODEL : value
}
@@ -143,12 +143,6 @@ export interface ClientRelation {
export interface SelectOption { export interface SelectOption {
value: string value: string
label: string label: string
// Couleur de fond optionnelle (hex #RRGGBB), reportee pour les sites afin
// de colorer les tags selectionnes en consultation comme en edition.
color?: string
// Couleur de texte optionnelle (hex). Sites : blanc, pour rester lisible
// sur le fond colore du tag.
textColor?: string
} }
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */ /** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
@@ -272,7 +266,7 @@ export function categoryOptionsOf(categories: CategoryRead[] | undefined): Categ
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */ /** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] { export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'], color: s.color, textColor: '#FFFFFF' })) 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. */ /** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed client. */
@@ -323,77 +317,6 @@ export function mapAddressView(address: AddressRead): AddressView {
} }
} }
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Information porte au moins une donnee. ERP-193 : en
* consultation on masque les onglets vides ; Information n'echappe pas a la
* regle malgre son statut d'onglet d'atterrissage par defaut.
*/
export function hasInformationData(client: ClientDetail): boolean {
return [
client.description,
client.competitors,
client.foundedAt,
client.employeesCount,
client.revenueAmount,
client.profitAmount,
client.directorName,
].some(hasValue)
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(client: ClientDetail): boolean {
const draft = mapAccountingDraft(client)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (client.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
* ET tout onglet de donnees vide. L'ordre reproduit `buildClientFormTabKeys`.
* Retourne `[]` tant que le client n'est pas charge.
*/
export function clientConsultationVisibleTabs(
client: ClientDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!client) {
return []
}
const visible: string[] = []
if (hasInformationData(client)) {
visible.push('information')
}
if ((client.contacts ?? []).length > 0) {
visible.push('contact')
}
if ((client.addresses ?? []).length > 0) {
visible.push('address')
}
if (options.canAccountingView && hasAccountingData(client)) {
visible.push('accounting')
}
return visible
}
/** /**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta * — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
@@ -138,12 +138,6 @@ export interface AccountingDraft {
export interface SelectOption { export interface SelectOption {
value: string value: string
label: string label: string
// Couleur de fond optionnelle (hex #RRGGBB), reportee pour les sites afin
// de colorer les tags selectionnes en consultation comme en edition.
color?: string
// Couleur de texte optionnelle (hex). Sites : blanc, pour rester lisible
// sur le fond colore du tag.
textColor?: string
} }
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */ /** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
@@ -247,7 +241,7 @@ export function categoryOptionsOf(categories: CategoryRead[] | undefined): Categ
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */ /** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] { export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'], color: s.color, textColor: '#FFFFFF' })) 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 fournisseur. */ /** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed fournisseur. */
@@ -298,78 +292,6 @@ export function mapAddressView(address: AddressRead): AddressView {
} }
} }
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Information porte au moins une donnee (volume previsionnel
* inclus, specifique fournisseur). ERP-193 : en consultation on masque les
* onglets vides, Information comprise.
*/
export function hasInformationData(supplier: SupplierDetail): boolean {
return [
supplier.description,
supplier.competitors,
supplier.foundedAt,
supplier.employeesCount,
supplier.revenueAmount,
supplier.profitAmount,
supplier.directorName,
supplier.volumeForecast,
].some(hasValue)
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(supplier: SupplierDetail): boolean {
const draft = mapAccountingDraft(supplier)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (supplier.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
* ET tout onglet de donnees vide. L'ordre reproduit `buildSupplierFormTabKeys`.
* Retourne `[]` tant que le fournisseur n'est pas charge.
*/
export function supplierConsultationVisibleTabs(
supplier: SupplierDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!supplier) {
return []
}
const visible: string[] = []
if (hasInformationData(supplier)) {
visible.push('information')
}
if ((supplier.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((supplier.addresses ?? []).length > 0) {
visible.push('addresses')
}
if (options.canAccountingView && hasAccountingData(supplier)) {
visible.push('accounting')
}
return visible
}
/** /**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta * — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
@@ -4,6 +4,7 @@
<div <div
v-if="modelValue" v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="cancel"
> >
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl"> <div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-neutral-900"> <h3 class="text-lg font-semibold text-neutral-900">
+35
View File
@@ -0,0 +1,35 @@
<template>
<div class="flex h-full items-center justify-center">
<p class="text-neutral-500">{{ $t('auth.logout') }}...</p>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: 'auth' })
const auth = useAuthStore()
const { resetSidebar } = useSidebar()
const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite()
const { resetAuditLog } = useAuditLog()
const { resetCategoriesAdmin } = useCategoriesAdmin()
onMounted(async () => {
try {
await auth.logout()
} finally {
// Les resets sont garantis meme si auth.logout() rejette : eviter
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// l'ancien. Toutes les fonctions reset sont synchrones et ne
// peuvent pas throw (juste des assignations reactives).
// navigateTo est dans le finally pour garantir la redirection
// meme si auth.logout() lance une exception (ex: reseau coupé).
resetSidebar()
resetModules()
resetCurrentSite()
resetAuditLog()
resetCategoriesAdmin()
await navigateTo('/login')
}
})
</script>
@@ -1,109 +0,0 @@
<template>
<!-- Padding vertical piloté par la page (1er bloc sans pt, dernier sans pb). -->
<div>
<!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2>
<div class="flex items-center gap-8">
<MalioButton
variant="secondary"
icon-name="mdi:weight"
icon-position="left"
:label="t('logistique.weighingTickets.form.weighbridge.auto')"
:disabled="disabled"
@click="$emit('request-auto')"
/>
<MalioButton
variant="primary"
icon-name="mdi:weight"
icon-position="left"
:label="t('logistique.weighingTickets.form.weighbridge.manual')"
:disabled="disabled"
@click="$emit('request-manual')"
/>
</div>
</div>
<!-- Ligne : Date/heure, Poids, DSD. L'immatriculation et « Tout format »
vivent désormais dans les 4 champs du haut, hors des blocs (ERP-193). -->
<div class="mt-6 grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Date/heure de la pesée — date du jour + heure courante par défaut
(RG-5.07), ré-horodatée à la validation de la pesée. -->
<MalioDateTime
:model-value="block.date"
:label="t('logistique.weighingTickets.form.date')"
:required="true"
:editable="true"
:disabled="disabled"
:error="errors.date"
@update:model-value="(v: string | null) => emitBlock('date', v)"
/>
<!-- Poids : champ texte verrouillé sur les chiffres, toujours désactivé
(rempli par la pesée, jamais saisi à la main — RG-5.07). -->
<MalioInputText
:model-value="weightDisplay"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.weight')"
:required="true"
:disabled="true"
:error="errors.weight"
/>
<!-- DSD : champ texte verrouillé sur les chiffres, toujours désactivé
(rempli par la pesée — RG-5.04 / RG-5.07). -->
<MalioInputText
:model-value="dsdDisplay"
:mask="NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.dsd')"
:required="true"
:disabled="true"
:error="errors.dsd"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { NUMERIC_MASK } from '~/modules/logistique/utils/weighingMasks'
/**
* Bloc de pesée (« Poids à vide » ou « Poids à plein ») de l'écran Ticket de pesée.
* Champs Date/heure / Poids / DSD + boutons de pesée (bascule / manuelle). Depuis
* ERP-193, la contrepartie, l'immatriculation et « Tout format » sont remontés dans
* les 4 champs du haut de page (hors blocs). Masque numérique factorisé dans
* `utils/weighingMasks`.
*/
const props = withDefaults(defineProps<{
/** Identifiant technique du bloc (pour les `id` de champs uniques). */
blockId: string
title: string
block: WeighingBlockState
/** Erreurs 422 par champ (propertyPath → message). */
errors?: Record<string, string>
disabled?: boolean
}>(), {
errors: () => ({}),
disabled: false,
})
const emit = defineEmits<{
'update:block': [field: keyof WeighingBlockState, value: unknown]
'request-auto': []
'request-manual': []
}>()
const { t } = useI18n()
// Poids / DSD : champs texte → on présente l'entier sous forme de chaîne (vide
// tant que la pesée n'a pas rempli la valeur).
const weightDisplay = computed(() => (props.block.weight === null ? '' : String(props.block.weight)))
const dsdDisplay = computed(() => (props.block.dsd === null ? '' : String(props.block.dsd)))
/** Remonte la mutation d'un champ du bloc au parent (état des pesées centralisé). */
function emitBlock(field: keyof WeighingBlockState, value: unknown): void {
emit('update:block', field, value)
}
</script>
@@ -1,61 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// useApi / useI18n sont des auto-imports Nuxt : on les expose en globals.
const mockPost = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ post: mockPost }))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
const { useWeighbridge } = await import('../useWeighbridge')
describe('useWeighbridge', () => {
beforeEach(() => {
mockPost.mockReset()
})
it('AUTO : POST { mode: AUTO } sans toast et renvoie la lecture', async () => {
mockPost.mockResolvedValue({ weight: 23187, dsd: 42, mode: 'AUTO' })
const { triggerAuto } = useWeighbridge()
const reading = await triggerAuto()
expect(mockPost).toHaveBeenCalledWith(
'/weighbridge_readings',
{ mode: 'AUTO' },
expect.objectContaining({ toast: false }),
)
expect(reading).toEqual({ weight: 23187, dsd: 42, mode: 'AUTO' })
})
it('MANUAL : POST { mode: MANUAL, weight, dsd } et renvoie la lecture', async () => {
// Le DSD est saisi par l'opérateur et conservé tel quel (ERP-193).
mockPost.mockResolvedValue({ weight: 5000, dsd: 16619, mode: 'MANUAL' })
const { triggerManual } = useWeighbridge()
const reading = await triggerManual(5000, 16619)
expect(mockPost).toHaveBeenCalledWith(
'/weighbridge_readings',
{ mode: 'MANUAL', weight: 5000, dsd: 16619 },
expect.objectContaining({ toast: false }),
)
expect(reading.dsd).toBe(16619)
})
it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => {
const { extractWeighbridgeError } = useWeighbridge()
const error = { response: { status: 503, _data: { title: 'Pont bascule indisponible', detail: 'Passez en pesée manuelle.' } } }
expect(extractWeighbridgeError(error)).toBe('Passez en pesée manuelle.')
})
it('erreur sans payload exploitable : retombe sur le libellé i18n générique', () => {
const { extractWeighbridgeError } = useWeighbridge()
expect(extractWeighbridgeError(new Error('network')))
.toBe('logistique.weighingTickets.form.weighbridge.unavailable')
})
it('triggerAuto propage l\'erreur API (gestion par l\'écran)', async () => {
mockPost.mockRejectedValue({ response: { status: 503 } })
const { triggerAuto } = useWeighbridge()
await expect(triggerAuto()).rejects.toBeDefined()
})
})
@@ -1,228 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
// `nowIsoDateTime` est importé par le composable : on le stubbe pour un instant déterministe.
vi.mock('~/shared/utils/date', () => ({ nowIsoDateTime: () => '2026-06-22T08:30:00' }))
const { useWeighingTicketForm } = await import('../useWeighingTicketForm')
describe('useWeighingTicketForm', () => {
it('initialise les 2 blocs à la date/heure courante (RG-5.07), sans poids ni DSD', () => {
const form = useWeighingTicketForm()
expect(form.empty.date).toBe('2026-06-22T08:30:00')
expect(form.full.date).toBe('2026-06-22T08:30:00')
expect(form.empty.weight).toBeNull()
expect(form.empty.dsd).toBeNull()
expect(form.counterpartyType.value).toBeNull()
})
// ── Omission des requis vides (compact) ──────────────────────────────────
it('buildDraftPayload : brouillon vierge → pas de champ requis ni de bloc non pesé', () => {
const form = useWeighingTicketForm()
// Formulaire vierge : counterpartyType / immatriculation non remplis, aucune pesée.
const payload = form.buildDraftPayload()
// Absents (et non null) → le back laisse jouer les contraintes du groupe finalize.
expect(payload).not.toHaveProperty('counterpartyType')
expect(payload).not.toHaveProperty('immatriculation')
// Bloc non pesé → ni poids ni date (on n'envoie pas une date de pesée sans pesée).
expect(payload).not.toHaveProperty('emptyWeight')
expect(payload).not.toHaveProperty('emptyDate')
// Seul le booléen « Tout format » reste.
expect(payload.plateFreeFormat).toBe(false)
})
// ── Pesée obligatoire front-only (RG-5.07) ───────────────────────────────
it('missingWeighingFields liste Poids/DSD manquants, puis vide après pesée', () => {
const form = useWeighingTicketForm()
expect(form.missingWeighingFields('empty')).toEqual(['emptyWeight', 'emptyDsd'])
expect(form.missingWeighingFields('full')).toEqual(['fullWeight', 'fullDsd'])
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
expect(form.missingWeighingFields('empty')).toEqual([])
})
// ── Contrepartie conditionnelle (RG-5.03) ────────────────────────────────
it('CLIENT : ne conserve que le client, purge supplier et otherLabel', () => {
const form = useWeighingTicketForm()
form.supplierIri.value = '/api/suppliers/3'
form.otherLabel.value = 'Particulier'
form.setCounterpartyType('CLIENT')
form.clientIri.value = '/api/clients/629'
expect(form.counterpartyField.value).toBe('client')
expect(form.supplierIri.value).toBeNull()
expect(form.otherLabel.value).toBeNull()
const payload = form.buildDraftPayload()
expect(payload.counterpartyType).toBe('CLIENT')
expect(payload.client).toBe('/api/clients/629')
expect(payload).not.toHaveProperty('supplier')
expect(payload).not.toHaveProperty('otherLabel')
})
it('FOURNISSEUR : ne conserve que le supplier', () => {
const form = useWeighingTicketForm()
form.clientIri.value = '/api/clients/1'
form.setCounterpartyType('FOURNISSEUR')
form.supplierIri.value = '/api/suppliers/7'
expect(form.counterpartyField.value).toBe('supplier')
expect(form.clientIri.value).toBeNull()
expect(form.buildDraftPayload().supplier).toBe('/api/suppliers/7')
})
it('AUTRE : ne conserve que le libellé libre', () => {
const form = useWeighingTicketForm()
form.clientIri.value = '/api/clients/1'
form.setCounterpartyType('AUTRE')
form.otherLabel.value = 'Reprise interne'
expect(form.counterpartyField.value).toBe('other')
expect(form.clientIri.value).toBeNull()
expect(form.buildDraftPayload().otherLabel).toBe('Reprise interne')
})
it('buildDraftPayload : type choisi mais champ associé vide → contrepartie omise (pas de 500 chk_wt_*_branch)', () => {
const form = useWeighingTicketForm()
// L'opérateur ouvre le menu « Client » mais n'a pas encore choisi le client.
form.setCounterpartyType('CLIENT')
const draft = form.buildDraftPayload()
// On n'émet ni le type ni la FK : un brouillon incohérent serait rejeté en 500 par le back.
expect(draft).not.toHaveProperty('counterpartyType')
expect(draft).not.toHaveProperty('client')
// En revanche la validation envoie toujours le type, pour déclencher la 422 métier.
expect(form.buildValidatePayload().counterpartyType).toBe('CLIENT')
})
it('buildDraftPayload : AUTRE avec libellé vide → contrepartie omise', () => {
const form = useWeighingTicketForm()
form.setCounterpartyType('AUTRE')
form.otherLabel.value = ' '
const draft = form.buildDraftPayload()
expect(draft).not.toHaveProperty('counterpartyType')
expect(draft).not.toHaveProperty('otherLabel')
})
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
it('immatriculation et plateFreeFormat sont partagés (une seule valeur)', () => {
const form = useWeighingTicketForm()
form.immatriculation.value = 'AB-123-CD'
form.plateFreeFormat.value = true
// Les 2 payloads (brouillon + validation) reflètent la même valeur.
expect(form.buildDraftPayload().immatriculation).toBe('AB-123-CD')
expect(form.buildDraftPayload().plateFreeFormat).toBe(true)
expect(form.buildValidatePayload().immatriculation).toBe('AB-123-CD')
expect(form.buildValidatePayload().plateFreeFormat).toBe(true)
})
// ── Application d'une lecture de pesée ────────────────────────────────────
it('applyReading remplit poids / DSD / mode et ré-horodate le bloc à l\'instant de la pesée', () => {
const form = useWeighingTicketForm()
// Date périmée (ouverture du formulaire bien avant la pesée).
form.empty.date = '2020-01-01T00:00:00'
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
// La pesée validée ré-horodate le bloc à maintenant (stub 2026-06-22T08:30:00).
expect(form.empty.date).toBe('2026-06-22T08:30:00')
expect(form.empty.weight).toBe(7150)
expect(form.empty.dsd).toBe(1)
expect(form.empty.mode).toBe('AUTO')
// Pesée manuelle : le DSD saisi (16619) est conservé tel quel (ERP-193).
form.applyReading(form.full, { weight: 14300, dsd: 16619, mode: 'MANUAL' })
expect(form.full.weight).toBe(14300)
expect(form.full.dsd).toBe(16619)
expect(form.full.mode).toBe('MANUAL')
})
it('buildDraftPayload porte les pesées effectuées ; buildValidatePayload les 4 champs du haut', () => {
const form = useWeighingTicketForm()
form.setCounterpartyType('CLIENT')
form.clientIri.value = '/api/clients/1'
form.immatriculation.value = 'AB-123-CD'
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' })
// Le brouillon porte LES DEUX pesées effectuées.
const draft = form.buildDraftPayload()
expect(draft.emptyWeight).toBe(7150)
expect(draft.emptyMode).toBe('AUTO')
expect(draft.fullWeight).toBe(14300)
expect(draft.fullMode).toBe('AUTO')
// La validation ne porte que les 4 champs du haut (pesées déjà persistées).
const validate = form.buildValidatePayload()
expect(validate.counterpartyType).toBe('CLIENT')
expect(validate.client).toBe('/api/clients/1')
expect(validate.immatriculation).toBe('AB-123-CD')
expect(validate).not.toHaveProperty('emptyWeight')
expect(validate).not.toHaveProperty('fullWeight')
})
// ── Pré-remplissage (écran Modification, ERP-190) ─────────────────────────
it('hydrate pré-remplit l\'état depuis le détail (datetime ISO ramené en local, heure conservée)', () => {
const form = useWeighingTicketForm()
form.hydrate({
id: 9,
counterpartyType: 'CLIENT',
client: { '@id': '/api/clients/629' },
immatriculation: 'AB-123-CD',
plateFreeFormat: false,
emptyDate: '2026-06-17T09:00:00+02:00',
emptyWeight: 7150,
emptyDsd: 1,
emptyMode: 'AUTO',
fullDate: '2026-06-17T09:12:00+02:00',
fullWeight: 14300,
fullDsd: 2,
fullMode: 'AUTO',
})
expect(form.ticketId.value).toBe(9)
expect(form.counterpartyType.value).toBe('CLIENT')
expect(form.counterpartyField.value).toBe('client')
expect(form.clientIri.value).toBe('/api/clients/629')
expect(form.immatriculation.value).toBe('AB-123-CD')
// Datetime back (avec fuseau) -> local sans fuseau, heure conservée pour MalioDateTime.
expect(form.empty.date).toBe('2026-06-17T09:00:00')
expect(form.full.date).toBe('2026-06-17T09:12:00')
expect(form.empty.weight).toBe(7150)
expect(form.full.weight).toBe(14300)
})
it('hydrate gère les champs null omis (skip_null_values) avec des défauts', () => {
const form = useWeighingTicketForm()
form.hydrate({ id: 5, counterpartyType: 'AUTRE', otherLabel: 'Reprise' })
expect(form.otherLabel.value).toBe('Reprise')
expect(form.supplierIri.value).toBeNull()
expect(form.plateFreeFormat.value).toBe(false)
// Pas de date back -> repli sur l'instant courant (stub 2026-06-22T08:30:00).
expect(form.empty.date).toBe('2026-06-22T08:30:00')
expect(form.empty.weight).toBeNull()
})
it('buildDraftPayload après hydrate porte contrepartie + véhicule + les 2 pesées', () => {
const form = useWeighingTicketForm()
form.hydrate({
id: 9,
status: 'VALIDATED',
counterpartyType: 'CLIENT',
client: { '@id': '/api/clients/629' },
immatriculation: 'AB-123-CD',
emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
})
expect(form.status.value).toBe('VALIDATED')
const payload = form.buildDraftPayload()
expect(payload.counterpartyType).toBe('CLIENT')
expect(payload.client).toBe('/api/clients/629')
expect(payload.emptyWeight).toBe(7150)
expect(payload.fullWeight).toBe(14300)
expect(payload.immatriculation).toBe('AB-123-CD')
})
})
@@ -1,44 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useWeighingTicketReferentials } from '../useWeighingTicketReferentials'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests des référentiels Client/Fournisseur de l'écran ticket de pesée (M5).
* Contrat couvert (ERP-208) : `load(siteId)` filtre les deux endpoints par site
* courant via `siteId[]` ; sans site → listes complètes (param absent).
*/
describe('useWeighingTicketReferentials', () => {
beforeEach(() => {
mockApiGet.mockReset()
mockApiGet.mockResolvedValue({ member: [] })
})
it('passe siteId[] aux deux endpoints quand un site courant est fourni', async () => {
const { load } = useWeighingTicketReferentials()
await load(7)
const clientsCall = mockApiGet.mock.calls.find(c => c[0] === '/clients')
const suppliersCall = mockApiGet.mock.calls.find(c => c[0] === '/suppliers')
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
expect(suppliersCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
})
it('ne passe pas siteId[] quand aucun site (liste complète)', async () => {
const { load } = useWeighingTicketReferentials()
await load(null)
const clientsCall = mockApiGet.mock.calls.find(c => c[0] === '/clients')
expect(clientsCall?.[1]).not.toHaveProperty('siteId[]')
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false' })
})
it('mappe les membres Hydra en options { value: @id, label: companyName }', async () => {
mockApiGet.mockResolvedValue({ member: [{ '@id': '/api/clients/3', companyName: 'ACME' }] })
const { load, clients } = useWeighingTicketReferentials()
await load(7)
expect(clients.value).toEqual([{ value: '/api/clients/3', label: 'ACME' }])
})
})
@@ -1,58 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useWeighingTicketsRepository, type WeighingTicket } from '../useWeighingTicketsRepository'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests du repertoire des tickets de pesee (M5, ERP-188).
*
* `useWeighingTicketsRepository` est une fine enveloppe de
* `usePaginatedList<WeighingTicket>` sur `/weighing_tickets`. Les invariants
* generiques de pagination sont deja couverts par `usePaginatedList.test.ts` ;
* on verifie ici le CONTRAT propre au repertoire :
* - la ressource ciblee est bien `/weighing_tickets` ;
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* renvoie un tableau plat sans pagination) ;
* - DEFAUT 25 ITEMS/PAGE : la liste etant consultee en volume, le premier
* fetch demande 25 items (et non le defaut 10) — l'utilisateur peut toujours
* rebasculer via le selecteur.
*/
describe('useWeighingTicketsRepository', () => {
beforeEach(() => {
mockApiGet.mockReset()
})
/** Une page de tickets Hydra minimale. */
const PAGE: WeighingTicket[] = [
{
id: 1,
status: 'VALIDATED',
number: '86-TP-0001',
client: { id: 7, companyName: 'ACME' },
supplier: null,
otherLabel: null,
displayDate: '2026-06-17T09:12:00+02:00',
netWeight: 7150,
},
]
it('cible /weighing_tickets en Hydra avec 25 items/page par defaut', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useWeighingTicketsRepository()
await repo.fetch()
expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/weighing_tickets')
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
expect(opts).toMatchObject({
toast: false,
headers: { Accept: 'application/ld+json' },
})
expect(repo.itemsPerPage.value).toBe(25)
expect(repo.items.value).toEqual(PAGE)
expect(repo.totalItems.value).toBe(1)
})
})
@@ -1,72 +0,0 @@
/**
* Pesée au pont bascule (M5, ERP-189) — déclenche une lecture de poids via
* `POST /api/weighbridge_readings` (spec-back § 4.2). Action autonome : le ticket
* n'existe pas encore quand on pèse depuis le formulaire principal.
*
* Deux modes :
* - AUTO (« Pesée bascule ») : le serveur résout le site courant, lit le poids
* (stub aléatoire au M5) et alloue le DSD. Peut échouer (RG-5.06 → 503) : le
* pont est indisponible, on invite l'utilisateur à passer en pesée manuelle.
* - MANUAL (« Pesée manuelle ») : poids + DSD saisis par l'opérateur ; le serveur
* les conserve tels quels — plus d'auto-incrément (ERP-193).
*
* Composable UI-agnostique : il appelle l'API (`useApi`, jamais `$fetch`) et
* renvoie la lecture, ou lève l'erreur — la gestion de la modal/de l'affichage
* reste à la charge de l'écran. `extractWeighbridgeError` factorise la lecture
* du message d'erreur 503 (RG-5.06) pour l'afficher dans la modal.
*/
/** Mode de pesée — miroir de l'enum back. */
export type WeighbridgeMode = 'AUTO' | 'MANUAL'
/** Lecture renvoyée par le pont bascule (spec-back § 4.2). */
export interface WeighbridgeReading {
weight: number
dsd: number
mode: WeighbridgeMode
}
export function useWeighbridge() {
const api = useApi()
const { t } = useI18n()
/**
* Pesée bascule (AUTO). Le site courant est résolu serveur — rien à envoyer.
* `toast: false` : l'erreur (RG-5.06) est affichée inline dans la modal, pas
* en toast global.
*/
async function triggerAuto(): Promise<WeighbridgeReading> {
return await api.post<WeighbridgeReading>(
'/weighbridge_readings',
{ mode: 'AUTO' },
{ toast: false },
)
}
/**
* Pesée manuelle (MANUAL). Le poids ET le DSD sont saisis par l'opérateur (le
* DSD = numéro du pont réellement utilisé) et conservés tels quels (ERP-193).
*/
async function triggerManual(weight: number, dsd: number): Promise<WeighbridgeReading> {
return await api.post<WeighbridgeReading>(
'/weighbridge_readings',
{ mode: 'MANUAL', weight, dsd },
{ toast: false },
)
}
/**
* Message d'erreur de pesée bascule (RG-5.06). Le back renvoie un 503
* `{ title, detail }` (« Pont bascule indisponible » / « Passez en pesée
* manuelle. ») — on privilégie le `detail`, puis le `title`, sinon un libellé
* générique invitant à la pesée manuelle.
*/
function extractWeighbridgeError(error: unknown): string {
const data = (error as { response?: { _data?: unknown } })?.response?._data as
| { detail?: string, title?: string }
| undefined
return data?.detail || data?.title || t('logistique.weighingTickets.form.weighbridge.unavailable')
}
return { triggerAuto, triggerManual, extractWeighbridgeError }
}
@@ -1,53 +0,0 @@
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
import type { CounterpartyType, WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
/**
* Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back
* § 4.0.bis). Champs null OMIS du JSON (`skip_null_values`) → tous optionnels,
* lus avec un défaut côté hydratation du formulaire.
*/
export interface WeighingTicketDetail {
id: number
/** Cycle de vie (DRAFT/VALIDATED, ERP-193). */
status?: WeighingTicketStatus
/** Numéro `{siteCode}-TP-{NNNN}` — null tant que brouillon, immuable ensuite (RG-5.09). */
number?: string | null
/** Site rattaché (embarqué) — immuable (RG-5.09). */
site?: { id: number, name: string, code: string } | null
counterpartyType?: CounterpartyType | null
client?: { '@id': string, companyName: string } | null
supplier?: { '@id': string, companyName: string } | null
otherLabel?: string | null
immatriculation?: string | null
plateFreeFormat?: boolean
// Pesée à vide
emptyDate?: string | null
emptyWeight?: number | null
emptyDsd?: number | null
emptyMode?: WeighbridgeMode | null
// Pesée à plein
fullDate?: string | null
fullWeight?: number | null
fullDsd?: number | null
fullMode?: WeighbridgeMode | null
netWeight?: number | null
}
/**
* Charge le détail d'un ticket de pesée pour l'écran de modification (M5,
* ERP-190). `Accept: application/ld+json` impose l'enveloppe Hydra (relations
* embarquées : client/supplier/site). Appel via `useApi()` (jamais `$fetch`).
*/
export function useWeighingTicket() {
const api = useApi()
async function fetchTicket(id: number | string): Promise<WeighingTicketDetail> {
return await api.get<WeighingTicketDetail>(
`/weighing_tickets/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
return { fetchTicket }
}
@@ -1,309 +0,0 @@
import { computed, reactive, ref } from 'vue'
import { nowIsoDateTime } from '~/shared/utils/date'
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
/**
* État et logique du formulaire « Ajouter / Modifier un ticket de pesée » (M5,
* ERP-189). L'écran est composé de DEUX blocs empilés — pesée à vide puis pesée
* à plein — qui partagent un même véhicule.
*
* Points clés (spec-front § Écran Ajouter, spec-back § 2.4 / 2.9 / 2.10) :
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
* Changer de type purge les champs des autres types — aucune donnée fantôme.
* - **Immatriculation + « Tout format »** font partie des 4 champs du haut, hors
* blocs (ERP-193). Une seule valeur, partagée entre les 2 pesées (RG-5.01).
* - **Cycle brouillon -> validé (ERP-193)** : `buildDraftPayload()` persiste l'état
* courant (pesée enregistrée dès la validation de sa modale, même sans
* contrepartie/immat) via POST (création du brouillon) puis PATCH ; quand les 3
* champs du haut + les 2 pesées sont là, `buildValidatePayload()` finalise via
* `PATCH /weighing_tickets/{id}/validate` (numéro attribué, status VALIDATED).
*
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
*/
/** Type de contrepartie — miroir de l'enum back (spec-back § 2.9). */
export type CounterpartyType = 'CLIENT' | 'FOURNISSEUR' | 'AUTRE'
/** Saisie d'une pesée (bloc vide OU bloc plein). */
export interface WeighingBlockState {
/** Date/heure de la pesée (ISO local `YYYY-MM-DDTHH:mm:ss`) — date du jour + heure courante par défaut (RG-5.07). */
date: string | null
/** Poids en kg — readonly, rempli par la pesée (bascule ou manuelle). */
weight: number | null
/** DSD — pesée bascule : fourni par le pont ; pesée manuelle : saisi (RG-5.04, ERP-193). */
dsd: number | null
/** Mode de la dernière pesée appliquée au bloc. */
mode: WeighbridgeMode | null
}
/** Cycle de vie du ticket (miroir back, ERP-193). */
export type WeighingTicketStatus = 'DRAFT' | 'VALIDATED'
/** Forme minimale d'un détail de ticket consommée par `hydrate` (cf. useWeighingTicket). */
export interface WeighingTicketHydration {
id: number
status?: WeighingTicketStatus
counterpartyType?: CounterpartyType | null
client?: { '@id': string } | null
supplier?: { '@id': string } | null
otherLabel?: string | null
immatriculation?: string | null
plateFreeFormat?: boolean
emptyDate?: string | null
emptyWeight?: number | null
emptyDsd?: number | null
emptyMode?: WeighbridgeMode | null
fullDate?: string | null
fullWeight?: number | null
fullDsd?: number | null
fullMode?: WeighbridgeMode | null
}
/**
* Ramène une chaîne ISO datetime du back (`2026-06-17T09:00:00+02:00`) au format
* local `YYYY-MM-DDTHH:mm:ss` attendu par MalioDateTime (secondes, sans fuseau) :
* on garde les 19 premiers caractères (date + heure), on retire l'offset. Null si
* absente.
*/
function toLocalIsoDateTime(value: string | null | undefined): string | null {
return value ? value.slice(0, 19) : null
}
/**
* Retire les clés à valeur `null` d'un payload (pattern « omission des requis
* vides » M1). Avec `collectDenormalizationErrors` côté back, envoyer `null` sur
* un scalaire requis (ex. `counterpartyType`) produit une violation de TYPE
* opaque (« Cette valeur doit être de type string. ») au lieu du message métier
* `NotBlank` : une clé ABSENTE laisse au contraire jouer la contrainte `NotBlank`
* et son message FR. On omet donc les null ; les champs réellement requis non
* remplis déclenchent leur vrai message, les optionnels restent simplement absents.
*/
function compact(payload: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== null))
}
/** Crée l'état initial d'un bloc de pesée (date/heure = maintenant, RG-5.07). */
function emptyBlock(now: string): WeighingBlockState {
return {
date: now,
weight: null,
dsd: null,
mode: null,
}
}
export function useWeighingTicketForm() {
const now = nowIsoDateTime()
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────
const counterpartyType = ref<CounterpartyType | null>(null)
const clientIri = ref<string | null>(null)
const supplierIri = ref<string | null>(null)
const otherLabel = ref<string | null>(null)
/**
* Change le type de contrepartie et purge les champs devenus hors-sujet :
* un seul de client / supplier / otherLabel est conservé selon le type
* (RG-5.03 — pas de FK fantôme envoyée au back).
*/
function setCounterpartyType(type: CounterpartyType | null): void {
counterpartyType.value = type
if (type !== 'CLIENT') clientIri.value = null
if (type !== 'FOURNISSEUR') supplierIri.value = null
if (type !== 'AUTRE') otherLabel.value = null
}
// ── Véhicule : partagé entre les 2 blocs (RG-5.01) ────────────────────────
// Refs UNIQUES : les 2 blocs bindent la même valeur → connexion automatique.
const immatriculation = ref<string | null>(null)
const plateFreeFormat = ref<boolean>(false)
// ── Les deux pesées ───────────────────────────────────────────────────────
const empty = reactive<WeighingBlockState>(emptyBlock(now))
const full = reactive<WeighingBlockState>(emptyBlock(now))
// Id du ticket persisté (POST du 1er enregistrement de pesée) — pilote ensuite
// les PATCH (brouillon) puis la validation. Null tant que rien n'est persisté.
const ticketId = ref<number | null>(null)
// Cycle de vie courant (DRAFT tant que non validé, ERP-193).
const status = ref<WeighingTicketStatus>('DRAFT')
/**
* Champ de contrepartie attendu selon le type courant — utilisé par l'écran
* pour afficher conditionnellement le bon champ (RG-5.03).
*/
const counterpartyField = computed<'client' | 'supplier' | 'other' | null>(() => {
switch (counterpartyType.value) {
case 'CLIENT': return 'client'
case 'FOURNISSEUR': return 'supplier'
case 'AUTRE': return 'other'
default: return null
}
})
/**
* Champs de pesée manquants d'un bloc (Poids / DSD), RG-5.07. Le back rend ces
* colonnes nullable (workflow 2 temps) : l'obligation « une pesée a été
* effectuée » est donc portée côté front (règle front-only, ERP-101). Renvoie
* les `propertyPath` manquants (ex. `['emptyWeight', 'emptyDsd']`), prêts à
* être posés en erreur inline via `useFormErrors.setError`.
*/
function missingWeighingFields(which: 'empty' | 'full'): string[] {
const block = which === 'empty' ? empty : full
const missing: string[] = []
if (block.weight === null) missing.push(`${which}Weight`)
if (block.dsd === null) missing.push(`${which}Dsd`)
return missing
}
/**
* Applique une lecture de pesée (bascule/manuelle) à un bloc. La pesée étant
* effectuée À CET INSTANT, on (ré)horodate le bloc à maintenant : la date/heure
* du ticket reflète le moment réel de la pesée validée, pas l'ouverture du
* formulaire (RG-5.07).
*/
function applyReading(
block: WeighingBlockState,
reading: { weight: number, dsd: number, mode: WeighbridgeMode },
): void {
block.date = nowIsoDateTime()
block.weight = reading.weight
block.dsd = reading.dsd
block.mode = reading.mode
}
/** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */
function counterpartyPayload(): Record<string, unknown> {
switch (counterpartyType.value) {
case 'CLIENT': return { client: clientIri.value }
case 'FOURNISSEUR': return { supplier: supplierIri.value }
case 'AUTRE': return { otherLabel: otherLabel.value || null }
default: return {}
}
}
/**
* Contrepartie d'un BROUILLON : on n'envoie le type QUE si son champ associé est
* renseigné. Un type sans son champ (l'opérateur a ouvert le menu avant de
* choisir) est une contrepartie incohérente que le back devrait retirer (sinon
* les CHECK chk_wt_*_branch lèvent une 500). On évite donc de l'émettre côté
* front. La cohérence reste exigée à la validation : `buildValidatePayload()`
* envoie toujours le type, pour déclencher la 422 métier sur le champ manquant.
*/
function draftCounterpartyPayload(): Record<string, unknown> {
switch (counterpartyType.value) {
case 'CLIENT':
return clientIri.value ? { counterpartyType: 'CLIENT', client: clientIri.value } : {}
case 'FOURNISSEUR':
return supplierIri.value ? { counterpartyType: 'FOURNISSEUR', supplier: supplierIri.value } : {}
case 'AUTRE':
return otherLabel.value && otherLabel.value.trim() !== ''
? { counterpartyType: 'AUTRE', otherLabel: otherLabel.value }
: {}
default:
return {}
}
}
/**
* Champs d'un bloc de pesée, UNIQUEMENT s'il a été pesé (poids renseigné) — on
* n'envoie pas la date par défaut d'un bloc vierge (sinon le back stockerait une
* date de pesée sans poids). Noms de clés alignés sur les `propertyPath` back.
*/
function blockPayload(prefix: 'empty' | 'full', block: WeighingBlockState): Record<string, unknown> {
if (block.weight === null) return {}
return {
[`${prefix}Date`]: block.date,
[`${prefix}Weight`]: block.weight,
[`${prefix}Dsd`]: block.dsd,
[`${prefix}Mode`]: block.mode,
}
}
/**
* Payload de BROUILLON (POST création / PATCH mise à jour, ERP-193) : l'état
* courant complet (4 champs du haut + pesées effectuées). Aucun champ n'est
* requis ici (le back valide en mode relâché) — une pesée s'enregistre sans
* contrepartie ni immatriculation. Numéro/site/net attribués serveur.
*/
function buildDraftPayload(): Record<string, unknown> {
return compact({
...draftCounterpartyPayload(),
immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value,
...blockPayload('empty', empty),
...blockPayload('full', full),
})
}
/**
* Pré-remplit le formulaire à partir du détail d'un ticket existant (écran
* Modification, ERP-190). Le numéro et le site sont immuables (RG-5.09) →
* non repris dans l'état éditable (affichés en lecture seule par l'écran).
* Les dates ISO du back (datetime + fuseau) sont ramenées au format local
* `YYYY-MM-DDTHH:mm:ss` attendu par MalioDateTime (heure conservée).
*/
function hydrate(detail: WeighingTicketHydration): void {
ticketId.value = detail.id
status.value = detail.status ?? 'DRAFT'
counterpartyType.value = detail.counterpartyType ?? null
clientIri.value = detail.client?.['@id'] ?? null
supplierIri.value = detail.supplier?.['@id'] ?? null
otherLabel.value = detail.otherLabel ?? null
immatriculation.value = detail.immatriculation ?? null
plateFreeFormat.value = detail.plateFreeFormat ?? false
empty.date = toLocalIsoDateTime(detail.emptyDate) ?? now
empty.weight = detail.emptyWeight ?? null
empty.dsd = detail.emptyDsd ?? null
empty.mode = detail.emptyMode ?? null
full.date = toLocalIsoDateTime(detail.fullDate) ?? now
full.weight = detail.fullWeight ?? null
full.dsd = detail.fullDsd ?? null
full.mode = detail.fullMode ?? null
}
/**
* Payload de VALIDATION (PATCH /weighing_tickets/{id}/validate, ERP-193) : les
* 4 champs du haut (contrepartie + immatriculation + « Tout format »). Les pesées
* sont déjà persistées par les enregistrements brouillon ; le back rejoue ici la
* validation stricte (groupe `finalize` : 3 champs requis + 2 pesées) et attribue
* le numéro. Les `propertyPath` des 422 sont mappés inline par useFormErrors.
*/
function buildValidatePayload(): Record<string, unknown> {
return compact({
counterpartyType: counterpartyType.value,
...counterpartyPayload(),
immatriculation: immatriculation.value || null,
plateFreeFormat: plateFreeFormat.value,
})
}
return {
// contrepartie
counterpartyType,
counterpartyField,
clientIri,
supplierIri,
otherLabel,
setCounterpartyType,
// véhicule partagé
immatriculation,
plateFreeFormat,
// pesées
empty,
full,
applyReading,
missingWeighingFields,
// workflow
ticketId,
status,
hydrate,
buildDraftPayload,
buildValidatePayload,
}
}
@@ -1,71 +0,0 @@
import { ref } from 'vue'
/**
* Référentiels alimentant les selects de contrepartie de l'écran « Ticket de
* pesée » (M5, ERP-189) : liste des clients (M1) et des fournisseurs (M2).
*
* Collections récupérées en entier via l'échappatoire `?pagination=false`
* (référentiels de quelques dizaines d'entrées), avec l'en-tête
* `Accept: application/ld+json` imposé par API Platform 4 pour obtenir
* l'enveloppe Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`) —
* renvoyée telle quelle dans le payload POST/PATCH (relation ManyToOne).
*
* Miroir de `useClientReferentials` (M1). État 100 % local à l'instance.
*/
/** Option au format attendu par MalioSelect ({ label, value }). */
export interface RefOption {
value: string
label: string
}
interface PartyMember {
'@id': string
companyName: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useWeighingTicketReferentials() {
const api = useApi()
const clients = ref<RefOption[]>([])
const suppliers = ref<RefOption[]>([])
/**
* Récupère une collection complète (pagination désactivée) en Hydra. Filtre par
* site courant si `siteId` est fourni (ERP-208) : un tiers est rattaché à un site
* via les sites de ses adresses — param `siteId[]` déjà géré par les providers M1/M2.
*/
async function fetchAll(url: string, siteId?: number | null): Promise<PartyMember[]> {
const query: Record<string, unknown> = { pagination: 'false' }
if (siteId !== null && siteId !== undefined) {
query['siteId[]'] = [siteId]
}
const res = await api.get<{ member?: PartyMember[] }>(
url,
query,
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
/**
* Charge en parallèle clients + fournisseurs (résilient : un référentiel en
* échec — ex. 403 selon le rôle — laisse simplement son select vide sans
* faire échouer l'autre). `siteId` (site courant) filtre les listes par site
* (ERP-208) ; absent → listes complètes.
*/
async function load(siteId?: number | null): Promise<void> {
await Promise.allSettled([
fetchAll('/clients', siteId).then((list) => {
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
}),
fetchAll('/suppliers', siteId).then((list) => {
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
}),
])
}
return { clients, suppliers, load }
}
@@ -1,72 +0,0 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
import type { WeighingTicketStatus } from '~/modules/logistique/composables/useWeighingTicketForm'
/**
* Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la
* LISTE des tickets de pesee. Seul `companyName` alimente les colonnes
* « Client » / « Fournisseur » ; l'objet sort embarque (`client:read` /
* `supplier:read`) ou est carrement absent du JSON quand null (`skip_null_values`,
* spec-back § 4.0.bis) — d'ou le `?? null` systematique cote page.
*/
export interface WeighingTicketParty {
id: number
companyName: string | null
}
/**
* Vue MINIMALE d'un ticket de pesee pour la datatable (M5, ERP-188). Volontairement
* partielle : seuls les champs des colonnes (docx p.3) + l'id (navigation) sont
* types. Le detail complet (pesees vide/plein, immatriculation, site, DSD) releve
* de l'ecran Modification (ERP-190) — hors perimetre de cet ecran.
*
* Contrepartie mutuellement exclusive (RG-5.03) : un seul de `client` / `supplier`
* / `otherLabel` est renseigne ; les deux autres sont omis du JSON (null).
* `displayDate` = getter serveur `fullDate ?? emptyDate` (spec-back § 4.0).
* `netWeight` = plein vide en kg (RG-5.05).
*/
export interface WeighingTicket {
id: number
/** Cycle de vie : DRAFT (« En attente ») ou VALIDATED (« Terminée ») — ERP-193. */
status: WeighingTicketStatus
/** Numero metier `{siteCode}-TP-{NNNN}` — null tant que brouillon (RG-5.02). */
number: string | null
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
client: WeighingTicketParty | null
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
supplier: WeighingTicketParty | null
/** Libelle libre si contrepartie = Autre (RG-5.03), sinon absent. */
otherLabel: string | null
/** Date ISO du ticket (`fullDate ?? emptyDate`) — colonne « Date ». */
displayDate: string | null
/** Poids net en kg (= plein vide, RG-5.05) — colonne « Poids ». */
netWeight: number | null
}
/**
* Filtres de la liste des tickets de pesee, branches sur les query params de
* `GET /api/weighing_tickets` (spec-back § 4.1). La liste est par ailleurs
* cloisonnee par site courant cote back (`SiteScopedQueryExtension`, § 2.3) — le
* front n'a pas a envoyer le site.
*/
export interface WeighingTicketFilters {
search?: string
}
/**
* Liste des tickets de pesee (M5, ERP-188) — simple enveloppe de
* `usePaginatedList<WeighingTicket>` sur la ressource `/weighing_tickets`
* (URL API en snake_case ; la route Nuxt reste `/weighing-tickets`). Pagination
* serveur obligatoire (regle ABSOLUE n°13), etat 100 % local (regle ABSOLUE n°6).
*
* Miroir de `useCarriersRepository` (M4). Volontairement PAR INSTANCE (pas de
* singleton) : l'etat tableau est propre a l'ecran et meurt avec lui.
*/
export function useWeighingTicketsRepository() {
// Defaut 25 items/page (au lieu de 10) : la liste des tickets de pesee est
// consultee en volume. 25 fait partie des options [10, 25, 50] et reste sous le
// max serveur (50). L'utilisateur peut toujours basculer via le selecteur.
return usePaginatedList<WeighingTicket, WeighingTicketFilters>({
url: '/weighing_tickets',
defaultItemsPerPage: 25,
})
}
@@ -1,160 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
// ── Mocks des composables modules (le form RÉEL est conservé pour vérifier le
// pré-remplissage via hydrate). ─────────────────────────────────────────────
const mockFetchTicket = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockPush = vi.hoisted(() => vi.fn())
const mockOpen = vi.hoisted(() => vi.fn())
const mockRefLoad = vi.hoisted(() => vi.fn())
vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({
useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }),
}))
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: mockRefLoad }),
}))
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
}))
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: vi.fn(), patch: mockPatch }))
vi.stubGlobal('useRoute', () => ({ params: { id: '9' } }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
vi.stubGlobal('navigateTo', vi.fn())
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
globalThis.open = mockOpen
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
// ── Stubs de composants ──────────────────────────────────────────────────────
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) {
return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string })
},
})
// WeighingBlock stubbé (Date/Poids/DSD + boutons) — la contrepartie vit désormais
// dans les 4 champs du haut, hors bloc (ERP-193).
const BlockStub = defineComponent({
setup() { return () => h('div', { 'data-testid': 'block' }) },
})
const ModalStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
})
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioInputNumber: InputStub,
MalioSelect: InputStub,
MalioDateTime: InputStub,
MalioCheckbox: InputStub,
MalioModal: ModalStub,
WeighingBlock: BlockStub,
}
// Monte la page (setup async : top-level await) via Suspense.
async function mountPage() {
const wrapper = mount(defineComponent({
components: { EditPage },
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
const DETAIL = {
id: 9,
status: 'VALIDATED',
number: '86-TP-0001',
site: { id: 1, name: 'Chatellerault', code: '86' },
counterpartyType: 'CLIENT',
client: { '@id': '/api/clients/629', companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
immatriculation: 'AB-123-CD',
plateFreeFormat: false,
emptyDate: '2026-06-17T09:00:00+02:00', emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
fullDate: '2026-06-17T09:12:00+02:00', fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
}
describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit)', () => {
beforeEach(() => {
mockFetchTicket.mockReset().mockResolvedValue({ ...DETAIL })
mockPatch.mockReset().mockResolvedValue({})
mockPush.mockReset()
mockOpen.mockReset()
mockRefLoad.mockReset().mockResolvedValue(undefined)
})
it('charge le ticket au montage (pré-remplissage via hydrate)', async () => {
await mountPage()
expect(mockFetchTicket).toHaveBeenCalledWith('9')
})
it('filtre les référentiels sur le SITE DU TICKET, pas le site courant (ERP-208)', async () => {
await mountPage()
// DETAIL.site.id = 1 → les listes sont chargées pour le site du ticket (immuable).
expect(mockRefLoad).toHaveBeenCalledWith(1)
})
it('ticket validé : action principale « Enregistrer » + « Imprimer » (pas « Valider »)', async () => {
const wrapper = await mountPage()
// DETAIL.status = VALIDATED → l'action principale s'intitule « Enregistrer ».
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(true)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(true)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
})
it('ticket en attente (DRAFT) : PAS de bouton « Imprimer », action principale « Valider »', async () => {
// Un brouillon n'a pas de numéro : le bon de pesée ne doit pas être imprimable.
mockFetchTicket.mockReset().mockResolvedValue({ ...DETAIL, status: 'DRAFT', number: null })
const wrapper = await mountPage()
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(false)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(true)
})
it('« Imprimer » ouvre le bon de pesée PDF servi par le back (RG-5.08)', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.print"]').trigger('click')
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
})
it('« Enregistrer » : PATCH brouillon puis PATCH /validate, retour à la liste', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click')
await flushPromises()
// 1. Persistance de l'état courant (brouillon) avec les 2 pesées.
expect(mockPatch).toHaveBeenCalledWith(
'/weighing_tickets/9',
expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }),
expect.objectContaining({ toast: false }),
)
// 2. Validation (back autoritaire) — ne porte que les 4 champs du haut.
expect(mockPatch).toHaveBeenCalledWith(
'/weighing_tickets/9/validate',
expect.objectContaining({ counterpartyType: 'CLIENT', immatriculation: 'AB-123-CD' }),
expect.objectContaining({ toast: false }),
)
// « Enregistrer » ouvre aussi le bon de pesée PDF (RG-5.08).
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
})
})
@@ -1,111 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
// ── Mocks des composables modules (le form RÉEL est conservé). ────────────────
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockPush = vi.hoisted(() => vi.fn())
const mockOpen = vi.hoisted(() => vi.fn())
const mockRefLoad = vi.hoisted(() => vi.fn())
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: mockRefLoad }),
}))
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
}))
// ── Auto-imports Nuxt stubbés globalement ───────────────────────────────────
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: mockPost, patch: mockPatch }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
vi.stubGlobal('navigateTo', vi.fn())
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), setError: vi.fn(), clearErrors: vi.fn(), handleApiError: vi.fn() }))
// Site courant (ERP-208) : id 7 → les référentiels doivent être chargés filtrés sur ce site.
vi.stubGlobal('useCurrentSite', () => ({ currentSite: ref({ id: 7, name: 'Site 7', color: '#000000' }) }))
globalThis.open = mockOpen
const NewPage = (await import('../weighing-tickets/new.vue')).default
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) { return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string }) },
})
const BlockStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'block' }) } })
const ModalStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
})
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioSelect: InputStub,
MalioDateTime: InputStub,
MalioCheckbox: InputStub,
MalioModal: ModalStub,
WeighingBlock: BlockStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { NewPage },
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Ajouter ticket de pesée (page /weighing-tickets/new)', () => {
beforeEach(() => {
mockPost.mockReset().mockResolvedValue({ id: 42 })
mockPatch.mockReset().mockResolvedValue({})
mockPush.mockReset()
mockOpen.mockReset()
mockRefLoad.mockReset().mockResolvedValue(undefined)
})
it('charge les référentiels filtrés sur le site courant au montage (ERP-208)', async () => {
await mountPage()
expect(mockRefLoad).toHaveBeenCalledWith(7)
})
it('un seul bouton « Valider » (pas de « Enregistrer » séparé)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(true)
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(false)
})
it('« Valider » : POST brouillon (création) puis PATCH /validate, PDF + retour liste', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').trigger('click')
await flushPromises()
// 1. Création du brouillon (POST) → récupère l'id.
expect(mockPost).toHaveBeenCalledWith(
'/weighing_tickets',
expect.any(Object),
expect.objectContaining({ toast: false }),
)
// 2. Validation (back autoritaire) sur l'id retourné.
expect(mockPatch).toHaveBeenCalledWith(
'/weighing_tickets/42/validate',
expect.any(Object),
expect.objectContaining({ toast: false }),
)
// 3. Ouverture du bon de pesée PDF + retour à la liste.
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/42/print.pdf', '_blank')
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
})
})
@@ -1,200 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, ref } from 'vue'
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
// La page ne les importe pas (auto-import) : on les expose en globals pour le
// runtime de test (happy-dom). Meme philosophie que les specs M1→M4.
const mockPush = vi.hoisted(() => vi.fn())
const mockApiGet = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
const mockFetch = vi.hoisted(() => vi.fn())
const mockReset = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
// Site courant (switcher global) : ref pilotable pour simuler un changement de site.
const currentSiteRef = ref<{ id: number } | null>(null)
vi.stubGlobal('useCurrentSite', () => ({ currentSite: currentSiteRef }))
// Le repository est lui aussi un auto-import : on controle les items renvoyes.
// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values).
vi.stubGlobal('useWeighingTicketsRepository', () => ({
items: ref([
{
id: 9,
number: '86-TP-0001',
client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
supplier: null,
otherLabel: null,
displayDate: '2026-06-17T09:12:00+02:00',
netWeight: 7150,
},
]),
totalItems: ref(1),
currentPage: ref(1),
itemsPerPage: ref(10),
itemsPerPageOptions: ref([10, 25, 50]),
fetch: mockFetch,
goToPage: vi.fn(),
setItemsPerPage: vi.fn(),
setFilters: vi.fn(),
reset: mockReset,
}))
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
globalThis.URL.revokeObjectURL = vi.fn()
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
const WeighingTicketsIndex = (await import('../weighing-tickets/index.vue')).default
// ── Stubs de composants ──────────────────────────────────────────────────────
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
// Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses
// cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes.
const capturedRows = ref<Array<Record<string, unknown>>>([])
const DataTableStub = defineComponent({
props: { items: { type: Array, default: () => [] } },
emits: ['row-click', 'update:page', 'update:per-page'],
setup(props, { emit }) {
return () => {
capturedRows.value = props.items as Array<Record<string, unknown>>
return h('div', { 'data-testid': 'datatable' },
(props.items as Array<Record<string, unknown>>).map(it =>
h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [
h('td', { 'data-cell': 'displayDate' }, it.displayDate as string),
h('td', { 'data-cell': 'netWeight' }, it.netWeight as string),
h('td', { 'data-cell': 'client' }, it.client as string),
h('td', { 'data-cell': 'supplier' }, it.supplier as string),
]),
),
)
}
},
})
const PageHeaderStub = defineComponent({
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
})
// Suivi des wrappers montés pour les démonter entre tests : sans cela, les
// watchers sur la ref module-level `currentSiteRef` (site courant) fuiteraient
// d'un test à l'autre et se déclencheraient en double.
const mountedWrappers: VueWrapper[] = []
function mountPage() {
const wrapper = mount(WeighingTicketsIndex, {
global: {
stubs: {
PageHeader: PageHeaderStub,
MalioButton: ButtonStub,
MalioDataTable: DataTableStub,
},
},
})
mountedWrappers.push(wrapper)
return wrapper
}
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
beforeEach(() => {
mockPush.mockReset()
mockApiGet.mockReset().mockResolvedValue(new Blob())
mockCan.mockReset().mockReturnValue(true)
mockFetch.mockReset()
mockReset.mockReset()
mockToastError.mockReset()
capturedRows.value = []
currentSiteRef.value = null
})
afterEach(() => {
// Démonte les composants montés → libère leurs watchers (site courant).
while (mountedWrappers.length > 0) {
mountedWrappers.pop()?.unmount()
}
})
it('charge la liste au montage', async () => {
mountPage()
await flushPromises()
expect(mockFetch).toHaveBeenCalled()
})
it('recharge la liste (page 1) quand le site courant change', async () => {
mountPage()
await flushPromises()
expect(mockReset).not.toHaveBeenCalled()
// Simule un switch de site via le switcher global.
currentSiteRef.value = { id: 2 }
await flushPromises()
expect(mockReset).toHaveBeenCalledTimes(1)
})
it('formate la date au format JJ-MM-AAAA', async () => {
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026')
})
it('formate le poids net en kg avec separateur de milliers', async () => {
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg')
})
it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => {
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE')
expect(wrapper.find('[data-cell="supplier"]').text()).toBe('')
})
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true)
})
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false)
})
it('navigue vers la modification au clic sur une ligne', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('tr[data-row-id="9"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit')
})
it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click')
await flushPromises()
expect(mockApiGet).toHaveBeenCalledWith(
'/weighing_tickets/export.xlsx',
expect.any(Object),
expect.objectContaining({ responseType: 'blob', toast: false }),
)
})
})
@@ -1,450 +0,0 @@
<template>
<div>
<!-- En-tête : retour vers la liste + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('logistique.weighingTickets.form.back')"
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- États de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('logistique.weighingTickets.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p>
<template v-else>
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
sépare chacun des 3 blocs (divide-y). -->
<div class="mt-[48px] flex flex-col divide-y divide-black">
<!-- 4 champs du haut : contrepartie + immatriculation + « Tout
format » (ERP-193, hors blocs de pesée). 1er bloc : pas de
padding-top (marge titreform = mt-[48px] standard). -->
<div class="pb-[20px]">
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioSelect
:model-value="form.counterpartyType.value"
:options="counterpartyOptions"
:label="t('logistique.weighingTickets.form.counterparty.type')"
:required="true"
empty-option-label=""
:error="errors.counterpartyType"
@update:model-value="onCounterpartyTypeChange"
/>
<MalioSelect
v-if="form.counterpartyField.value === 'supplier'"
:model-value="form.supplierIri.value"
:options="referentials.suppliers.value"
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
:required="true"
empty-option-label=""
:error="errors.supplier"
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
/>
<MalioSelect
v-else-if="form.counterpartyField.value === 'client'"
:model-value="form.clientIri.value"
:options="referentials.clients.value"
:label="t('logistique.weighingTickets.form.counterparty.client')"
:required="true"
empty-option-label=""
:error="errors.client"
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
/>
<MalioInputText
v-else-if="form.counterpartyField.value === 'other'"
:model-value="form.otherLabel.value"
:mask="FREE_TEXT_MASK"
:label="t('logistique.weighingTickets.form.counterparty.other')"
:required="true"
:error="errors.otherLabel"
@update:model-value="(v: string | null) => form.otherLabel.value = v"
/>
<!-- Pas de cellule vide sans type sélectionné : immat et « Tout
format » se collent au type ; le champ conditionnel les
décale une fois un type choisi. -->
<MalioInputText
:model-value="form.immatriculation.value"
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
:label="t('logistique.weighingTickets.form.immatriculation')"
:required="true"
:error="errors.immatriculation"
@update:model-value="(v: string | null) => form.immatriculation.value = v"
/>
<MalioCheckbox
id="plate-free-format"
:model-value="form.plateFreeFormat.value"
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
group-class="self-center"
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
/>
</div>
</div>
<!-- ── Bloc « Poids à vide » ──────────────────────────────────── -->
<WeighingBlock
class="py-[20px]"
block-id="empty"
:title="t('logistique.weighingTickets.form.emptyBlock')"
:block="form.empty"
:errors="emptyBlockErrors"
@update:block="(field, value) => updateBlock('empty', field, value)"
@request-auto="openAuto('empty')"
@request-manual="openManual('empty')"
/>
<!-- ── Bloc « Poids à plein » (dernier bloc : pas de padding-bottom,
pour ne pas écarter le bouton). ──────────────────────────── -->
<WeighingBlock
class="pt-[20px]"
block-id="full"
:title="t('logistique.weighingTickets.form.fullBlock')"
:block="form.full"
:errors="fullBlockErrors"
@update:block="(field, value) => updateBlock('full', field, value)"
@request-auto="openAuto('full')"
@request-manual="openManual('full')"
/>
</div>
<!-- Bas d'écran : « Imprimer » (ouvre le PDF back) + action principale
(« Valider » si brouillon, « Enregistrer » si déjà validé). -->
<div class="mt-12 flex justify-center gap-6">
<!-- « Imprimer » uniquement sur un ticket terminé (VALIDATED) : un
brouillon n'a pas de numéro et ne doit pas produire de bon. -->
<MalioButton
v-if="isValidated"
variant="secondary"
icon-name="mdi:printer-outline"
icon-position="left"
:label="t('logistique.weighingTickets.form.print')"
@click="printTicket"
/>
<MalioButton
variant="primary"
:label="primaryLabel"
:disabled="saving"
@click="submitPrimary"
/>
</div>
</template>
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
<MalioModal :dismissable="false" v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
</template>
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
<template #footer>
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
:disabled="autoModal.loading"
@click="confirmAuto"
/>
</template>
</MalioModal>
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
<MalioModal
:dismissable="false"
v-model="manualModal.open"
modal-class="max-w-md"
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
body-class="px-7 pt-9"
footer-class="px-7 justify-center pb-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
</template>
<div class="flex flex-col gap-2">
<MalioInputText
v-model="manualModal.weight"
:mask="MANUAL_NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.weight')"
:required="true"
:error="manualModal.errors.weight"
/>
<MalioInputText
v-model="manualModal.dsd"
:mask="MANUAL_NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.dsd')"
:required="true"
:error="manualModal.errors.dsd"
/>
</div>
<template #footer>
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.manual.save')"
:disabled="manualModal.loading"
@click="confirmManual"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicket, type WeighingTicketDetail } from '~/modules/logistique/composables/useWeighingTicket'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
import { MANUAL_NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
import { mapViolationsToRecord } from '~/shared/utils/api'
const { t } = useI18n()
const api = useApi()
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
// Modification réservée à `manage` (Admin / Bureau / Usine) — sinon retour liste.
if (!can('logistique.weighing_tickets.manage')) {
await navigateTo('/weighing-tickets')
}
const ticketId = route.params.id as string
const form = useWeighingTicketForm()
const weighbridge = useWeighbridge()
const referentials = useWeighingTicketReferentials()
const { fetchTicket } = useWeighingTicket()
const { errors, clearErrors, handleApiError } = useFormErrors()
const loading = ref(true)
const error = ref(false)
const saving = ref(false)
// Numéro immuable (RG-5.09), rappelé dans le titre — vide tant que brouillon.
const ticketNumber = ref<string>('')
const headerTitle = computed(() =>
ticketNumber.value
? t('logistique.weighingTickets.edit.title', { number: ticketNumber.value })
: t('logistique.weighingTickets.edit.titleFallback'),
)
// Libellé de l'action principale : « Valider » pour un brouillon (finalisation),
// « Enregistrer » pour un ticket déjà validé (mise à jour, ERP-193).
const isValidated = computed(() => form.status.value === 'VALIDATED')
const primaryLabel = computed(() =>
isValidated.value
? t('logistique.weighingTickets.form.save')
: t('logistique.weighingTickets.form.validate'),
)
useHead({ title: t('logistique.weighingTickets.edit.titleFallback') })
/** Retour vers la liste (flèche d'en-tête). */
function goBack(): void {
router.push('/weighing-tickets')
}
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
const counterpartyOptions = computed<RefOption[]>(() => [
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
])
function onCounterpartyTypeChange(value: string | number | null): void {
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
form.setCounterpartyType(type)
}
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
const emptyBlockErrors = computed<Record<string, string>>(() => ({
date: errors.emptyDate,
weight: errors.emptyWeight,
dsd: errors.emptyDsd,
}))
const fullBlockErrors = computed<Record<string, string>>(() => ({
date: errors.fullDate,
weight: errors.fullWeight,
dsd: errors.fullDsd,
}))
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
(form[target] as Record<string, unknown>)[field as string] = value
}
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
const autoModal = reactive({
open: false,
error: '',
loading: false,
target: 'empty' as 'empty' | 'full',
})
function openAuto(target: 'empty' | 'full'): void {
autoModal.target = target
autoModal.error = ''
autoModal.open = true
}
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
async function confirmAuto(): Promise<void> {
if (autoModal.loading) return
autoModal.loading = true
autoModal.error = ''
try {
const reading = await weighbridge.triggerAuto()
form.applyReading(form[autoModal.target], reading)
autoModal.open = false
await saveDraft()
}
catch (e) {
autoModal.error = weighbridge.extractWeighbridgeError(e)
}
finally {
autoModal.loading = false
}
}
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
const manualModal = reactive({
open: false,
loading: false,
target: 'empty' as 'empty' | 'full',
weight: null as string | null,
dsd: null as string | null,
errors: {} as Record<string, string>,
})
function openManual(target: 'empty' | 'full'): void {
manualModal.target = target
manualModal.weight = null
manualModal.dsd = null
manualModal.errors = {}
manualModal.open = true
}
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
async function confirmManual(): Promise<void> {
if (manualModal.loading) return
manualModal.errors = {}
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
if (weight === null || Number.isNaN(weight)) {
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
}
if (dsd === null || Number.isNaN(dsd)) {
manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
}
if (Object.keys(manualModal.errors).length > 0) return
manualModal.loading = true
try {
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
form.applyReading(form[manualModal.target], reading)
manualModal.open = false
await saveDraft()
}
catch (e) {
// 422 de pesée (poids/DSD ≤ 0, Assert\Positive) → erreur sous le BON champ
// (le propertyPath back `weight`/`dsd` = nom du champ de la modale). Sinon
// (503 pont indispo, réseau) → message générique sous le champ Poids.
const violations = mapViolationsToRecord((e as { response?: { _data?: unknown } })?.response?._data)
manualModal.errors = Object.keys(violations).length > 0
? violations
: { weight: weighbridge.extractWeighbridgeError(e) }
}
finally {
manualModal.loading = false
}
}
// ── Persistance / impression ──────────────────────────────────────────────────
/** Enregistre l'état courant en BROUILLON (PATCH). False sur erreur (422 inline). */
async function saveDraft(): Promise<boolean> {
clearErrors()
try {
await api.patch(`/weighing_tickets/${ticketId}`, form.buildDraftPayload(), { toast: false })
return true
}
catch (e) {
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
return false
}
}
/**
* Action principale : persiste l'état courant puis finalise/re-valide via
* PATCH /validate (back autoritaire : 3 champs du haut + 2 pesées). Ouvre le bon de
* pesée PDF (RG-5.08) — aussi bien à la validation d'un brouillon qu'à
* l'enregistrement d'un ticket déjà validé. Retour à la liste au succès.
*/
async function submitPrimary(): Promise<void> {
if (saving.value) return
saving.value = true
try {
if (!(await saveDraft())) return
await api.patch(`/weighing_tickets/${ticketId}/validate`, form.buildValidatePayload(), { toast: false })
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
router.push('/weighing-tickets')
}
catch (e) {
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
}
finally {
saving.value = false
}
}
/**
* « Imprimer » : ouvre le bon de pesée PDF servi par le back (Twig, ERP-192).
* Le front ne dessine AUCUN gabarit — il ouvre seulement l'URL (RG-5.08).
*/
function printTicket(): void {
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
}
/**
* Garantit que la contrepartie DÉJÀ ENREGISTRÉE (hydratée depuis le ticket) reste
* affichée même si la liste filtrée par site ne la contient pas (ticket antérieur
* à ERP-208, droits restreints sur /clients, contrepartie hors site…) : on injecte
* son option plutôt que de la purger. Évite toute perte silencieuse de la
* contrepartie en édition (ERP-208, retour review).
*/
function ensureSelectedOptionPresent(detail: WeighingTicketDetail): void {
const client = detail.client
if (client && !referentials.clients.value.some(o => o.value === client['@id'])) {
referentials.clients.value.push({ value: client['@id'], label: client.companyName })
}
const supplier = detail.supplier
if (supplier && !referentials.suppliers.value.some(o => o.value === supplier['@id'])) {
referentials.suppliers.value.push({ value: supplier['@id'], label: supplier.companyName })
}
}
onMounted(async () => {
try {
const detail = await fetchTicket(ticketId)
ticketNumber.value = detail.number ?? ''
form.hydrate(detail)
// Listes filtrées sur le SITE DU TICKET (immuable, RG-5.09) — pas le site
// courant — et chargées APRÈS hydrate pour ne jamais purger la sélection
// existante (pas de race load/hydrate, ERP-208).
await referentials.load(detail.site?.id ?? null)
ensureSelectedOptionPresent(detail)
}
catch {
error.value = true
}
finally {
loading.value = false
}
})
</script>
@@ -1,173 +0,0 @@
<template>
<div>
<PageHeader>
{{ t('logistique.weighingTickets.title') }}
<template #actions>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('logistique.weighingTickets.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList via useWeighingTicketsRepository :
pagination serveur (defaut 10), tri number DESC par defaut (cote back),
liste cloisonnee par site courant (spec-back § 2.3). Etat 100 % local
(regle ABSOLUE n°6). -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
:empty-message="t('logistique.weighingTickets.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
/>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('logistique.weighingTickets.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { formatDateFr, formatWeightKg } from '~/modules/logistique/utils/weighingTicketFormat'
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
// Site courant (switcher global) : la liste est cloisonnée par site côté back
// (spec-back § 2.3). Le front n'envoie PAS le site (résolu serveur) — il se
// contente de recharger quand le site change pour refléter le bon périmètre.
const { currentSite } = useCurrentSite()
useHead({ title: t('logistique.weighingTickets.title') })
// Bouton « + Ajouter » reserve a `manage` (Admin / Bureau / Usine). « Exporter »
// suit `view`. Compta et Commerciale n'ont aucun acces (item sidebar masque cote
// back) — spec-front § Acces.
const canManage = computed(() => can('logistique.weighing_tickets.manage'))
const canView = computed(() => can('logistique.weighing_tickets.view'))
const {
items: tickets,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadTickets,
goToPage,
setItemsPerPage,
reset: reloadFromFirstPage,
} = useWeighingTicketsRepository()
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
// Record<string, unknown>[]). La contrepartie est mutuellement exclusive (RG-5.03) :
// une seule des colonnes client / supplier / otherLabel est renseignee, les autres
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
const rows = computed(() => tickets.value.map(ticket => ({
id: ticket.id,
// Numéro vide tant que brouillon (attribué à la validation, ERP-193).
number: ticket.number ?? '',
client: ticket.client?.companyName ?? '',
supplier: ticket.supplier?.companyName ?? '',
otherLabel: ticket.otherLabel ?? '',
displayDate: formatDateFr(ticket.displayDate),
netWeight: formatWeightKg(ticket.netWeight),
status: t(ticket.status === 'VALIDATED'
? 'logistique.weighingTickets.status.validated'
: 'logistique.weighingTickets.status.draft'),
})))
const columns = [
{ key: 'number', label: t('logistique.weighingTickets.column.number') },
{ key: 'client', label: t('logistique.weighingTickets.column.client') },
{ key: 'supplier', label: t('logistique.weighingTickets.column.supplier') },
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
{ key: 'status', label: t('logistique.weighingTickets.column.status') },
]
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/weighing-tickets/${item.id}/edit`)
}
function goToCreate(): void {
router.push('/weighing-tickets/new')
}
// ── Export XLSX ─────────────────────────────────────────────────────────────
// Exporte toute la liste (site courant applique cote back, spec-back § 4.5).
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 (meme pattern M2/M3/M4).
const blob = await api.get<Blob>('/weighing_tickets/export.xlsx', {}, {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'tickets-pesee.xlsx')
}
catch {
toast.error({
title: t('logistique.weighingTickets.toast.error'),
message: t('logistique.weighingTickets.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)
}
// Changement de site courant → recharge la liste en page 1 (nouveau périmètre).
// usePaginatedList ne passe pas par useAsyncData : le refreshNuxtData() de
// switchSite ne l'atteint pas, d'où ce watcher explicite. On compare l'id pour
// ignorer l'hydratation initiale (même site) et les ré-affectations sans réel
// changement.
watch(() => currentSite.value?.id, (id, previousId) => {
if (id !== previousId) {
reloadFromFirstPage()
}
})
onMounted(loadTickets)
</script>
@@ -1,406 +0,0 @@
<template>
<div>
<!-- En-tête : retour vers la liste + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('logistique.weighingTickets.form.back')"
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1>
</div>
<!-- Form à plat, pleine largeur (sans box-shadow) : un filet noir 1px
sépare chacun des 3 blocs (divide-y). -->
<div class="mt-[48px] flex flex-col divide-y divide-black">
<!-- 4 champs du haut : contrepartie (type + champ conditionnel),
immatriculation, « Tout format » (ERP-193, hors blocs de pesée).
1er bloc : pas de padding-top (marge titreform = mt-[48px] standard). -->
<div class="pb-[20px]">
<div class="grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioSelect
:model-value="form.counterpartyType.value"
:options="counterpartyOptions"
:label="t('logistique.weighingTickets.form.counterparty.type')"
:required="true"
empty-option-label=""
:error="errors.counterpartyType"
@update:model-value="onCounterpartyTypeChange"
/>
<MalioSelect
v-if="form.counterpartyField.value === 'supplier'"
:model-value="form.supplierIri.value"
:options="referentials.suppliers.value"
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
:required="true"
empty-option-label=""
:error="errors.supplier"
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
/>
<MalioSelect
v-else-if="form.counterpartyField.value === 'client'"
:model-value="form.clientIri.value"
:options="referentials.clients.value"
:label="t('logistique.weighingTickets.form.counterparty.client')"
:required="true"
empty-option-label=""
:error="errors.client"
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
/>
<MalioInputText
v-else-if="form.counterpartyField.value === 'other'"
:model-value="form.otherLabel.value"
:mask="FREE_TEXT_MASK"
:label="t('logistique.weighingTickets.form.counterparty.other')"
:required="true"
:error="errors.otherLabel"
@update:model-value="(v: string | null) => form.otherLabel.value = v"
/>
<!-- Pas de cellule vide quand aucun type n'est choisi : immat et
« Tout format » se collent au type, et le champ conditionnel
les décale une fois un type sélectionné. -->
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) ; en « Tout
format », masque élargi. Partagée par les 2 pesées (RG-5.01). -->
<MalioInputText
:model-value="form.immatriculation.value"
:mask="form.plateFreeFormat.value ? FREE_PLATE_MASK : PLATE_MASK"
:label="t('logistique.weighingTickets.form.immatriculation')"
:required="true"
:error="errors.immatriculation"
@update:model-value="(v: string | null) => form.immatriculation.value = v"
/>
<MalioCheckbox
id="plate-free-format"
:model-value="form.plateFreeFormat.value"
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
group-class="self-center"
@update:model-value="(v: boolean) => form.plateFreeFormat.value = v"
/>
</div>
</div>
<!-- ── Bloc « Poids à vide » ──────────────────────────────────────── -->
<WeighingBlock
class="py-[20px]"
block-id="empty"
:title="t('logistique.weighingTickets.form.emptyBlock')"
:block="form.empty"
:errors="emptyBlockErrors"
@update:block="(field, value) => updateBlock('empty', field, value)"
@request-auto="openAuto('empty')"
@request-manual="openManual('empty')"
/>
<!-- ── Bloc « Poids à plein » (dernier bloc : pas de padding-bottom,
pour ne pas écarter le bouton « Valider »). ───────────────────── -->
<WeighingBlock
class="pt-[20px]"
block-id="full"
:title="t('logistique.weighingTickets.form.fullBlock')"
:block="form.full"
:errors="fullBlockErrors"
@update:block="(field, value) => updateBlock('full', field, value)"
@request-auto="openAuto('full')"
@request-manual="openManual('full')"
/>
</div>
<!-- « Valider » : persiste l'état courant (brouillon) puis finalise (3 champs
du haut + 2 pesées, validation back autoritaire) et ouvre le bon de
pesée PDF (RG-5.08, ERP-193). Toujours actif : les 422 s'affichent inline. -->
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.validate')"
:disabled="validating"
@click="submitValidate"
/>
</div>
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
<MalioModal :dismissable="false" v-model="autoModal.open" modal-class="max-w-md" footer-class="justify-center pb-6">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
</template>
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
<p v-if="autoModal.error" class="text-m-danger">{{ autoModal.error }}</p>
<template #footer>
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
:disabled="autoModal.loading"
@click="confirmAuto"
/>
</template>
</MalioModal>
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
<MalioModal
:dismissable="false"
v-model="manualModal.open"
modal-class="max-w-md"
header-class="mx-7 px-0 pt-6 pb-3 border-b border-black"
body-class="px-7 pt-9"
footer-class="px-7 justify-center pb-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
</template>
<div class="flex flex-col gap-2">
<MalioInputText
v-model="manualModal.weight"
:mask="MANUAL_NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.weight')"
:required="true"
:error="manualModal.errors.weight"
/>
<MalioInputText
v-model="manualModal.dsd"
:mask="MANUAL_NUMERIC_MASK"
:label="t('logistique.weighingTickets.form.manual.dsd')"
:required="true"
:error="manualModal.errors.dsd"
/>
</div>
<template #footer>
<MalioButton
variant="primary"
:label="t('logistique.weighingTickets.form.manual.save')"
:disabled="manualModal.loading"
@click="confirmManual"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
import { MANUAL_NUMERIC_MASK, PLATE_MASK, FREE_PLATE_MASK } from '~/modules/logistique/utils/weighingMasks'
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
import { mapViolationsToRecord } from '~/shared/utils/api'
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const { can } = usePermissions()
useHead({ title: t('logistique.weighingTickets.form.addTitle') })
// Création réservée à `manage` (Admin / Bureau / Usine) — sinon retour à la liste.
if (!can('logistique.weighing_tickets.manage')) {
await navigateTo('/weighing-tickets')
}
const form = useWeighingTicketForm()
const weighbridge = useWeighbridge()
const referentials = useWeighingTicketReferentials()
const { errors, clearErrors, handleApiError } = useFormErrors()
const validating = ref(false)
/** Retour vers la liste (flèche d'en-tête). */
function goBack(): void {
router.push('/weighing-tickets')
}
// ── Contrepartie (RG-5.03) — ordre maquette : Fournisseur / Client / Autre. ───
const counterpartyOptions = computed<RefOption[]>(() => [
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
])
function onCounterpartyTypeChange(value: string | number | null): void {
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
form.setCounterpartyType(type)
}
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
const emptyBlockErrors = computed<Record<string, string>>(() => ({
date: errors.emptyDate,
weight: errors.emptyWeight,
dsd: errors.emptyDsd,
}))
const fullBlockErrors = computed<Record<string, string>>(() => ({
date: errors.fullDate,
weight: errors.fullWeight,
dsd: errors.fullDsd,
}))
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
(form[target] as Record<string, unknown>)[field as string] = value
}
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
const autoModal = reactive({
open: false,
error: '',
loading: false,
target: 'empty' as 'empty' | 'full',
})
function openAuto(target: 'empty' | 'full'): void {
autoModal.target = target
autoModal.error = ''
autoModal.open = true
}
/** Déclenche la pesée bascule puis enregistre le brouillon (ERP-193). */
async function confirmAuto(): Promise<void> {
if (autoModal.loading) return
autoModal.loading = true
autoModal.error = ''
try {
const reading = await weighbridge.triggerAuto()
form.applyReading(form[autoModal.target], reading)
autoModal.open = false
await saveDraft()
}
catch (error) {
autoModal.error = weighbridge.extractWeighbridgeError(error)
}
finally {
autoModal.loading = false
}
}
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
const manualModal = reactive({
open: false,
loading: false,
target: 'empty' as 'empty' | 'full',
weight: null as string | null,
dsd: null as string | null,
errors: {} as Record<string, string>,
})
function openManual(target: 'empty' | 'full'): void {
manualModal.target = target
manualModal.weight = null
manualModal.dsd = null
manualModal.errors = {}
manualModal.open = true
}
/** Valide la saisie manuelle (poids + DSD), remplit le bloc puis enregistre le brouillon. */
async function confirmManual(): Promise<void> {
if (manualModal.loading) return
manualModal.errors = {}
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
const dsd = manualModal.dsd === null || manualModal.dsd === '' ? null : Number(manualModal.dsd)
if (weight === null || Number.isNaN(weight)) {
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
}
if (dsd === null || Number.isNaN(dsd)) {
manualModal.errors = { ...manualModal.errors, dsd: t('logistique.weighingTickets.form.manual.dsdRequired') }
}
if (Object.keys(manualModal.errors).length > 0) return
manualModal.loading = true
try {
const reading = await weighbridge.triggerManual(weight as number, dsd as number)
form.applyReading(form[manualModal.target], reading)
manualModal.open = false
await saveDraft()
}
catch (error) {
// 422 de pesée (poids/DSD ≤ 0, Assert\Positive) → erreur sous le BON champ
// (le propertyPath back `weight`/`dsd` = nom du champ de la modale). Sinon
// (503 pont indispo, réseau) → message générique sous le champ Poids.
const violations = mapViolationsToRecord((error as { response?: { _data?: unknown } })?.response?._data)
manualModal.errors = Object.keys(violations).length > 0
? violations
: { weight: weighbridge.extractWeighbridgeError(error) }
}
finally {
manualModal.loading = false
}
}
// ── Persistance ──────────────────────────────────────────────────────────────
interface TicketResponse { id: number }
/**
* Enregistre l'état courant en BROUILLON (ERP-193) : POST si le ticket n'existe pas
* encore (1ʳᵉ pesée enregistrée), PATCH ensuite. Renvoie false sur erreur (422
* mappée inline, ex. format d'immatriculation).
*/
async function saveDraft(): Promise<boolean> {
clearErrors()
try {
if (form.ticketId.value === null) {
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildDraftPayload(), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
form.ticketId.value = created.id
}
else {
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildDraftPayload(), { toast: false })
}
return true
}
catch (error) {
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
return false
}
}
/**
* « Valider » : persiste l'état courant puis finalise via PATCH /validate. La
* validation stricte (3 champs du haut + 2 pesées) est portée par le back ; les 422
* remontent inline. Succès → ouverture du bon de pesée PDF + retour à la liste.
*/
async function submitValidate(): Promise<void> {
if (validating.value) return
validating.value = true
try {
if (!(await saveDraft())) return
await api.patch(`/weighing_tickets/${form.ticketId.value}/validate`, form.buildValidatePayload(), { toast: false })
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
router.push('/weighing-tickets')
}
catch (error) {
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
}
finally {
validating.value = false
}
}
const { currentSite } = useCurrentSite()
/**
* Recharge les référentiels Client/Fournisseur pour le site donné, puis purge le
* tiers sélectionné s'il n'appartient plus à la liste du nouveau site (ERP-208).
*/
async function reloadReferentials(siteId: number | null): Promise<void> {
await referentials.load(siteId)
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
form.clientIri.value = null
}
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
form.supplierIri.value = null
}
}
onMounted(() => {
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
})
// Changement de site pendant la saisie → recharge les listes du nouveau site (ERP-208).
watch(() => currentSite.value?.id, (siteId) => {
reloadReferentials(siteId ?? null).catch(() => {})
})
</script>
@@ -1,52 +0,0 @@
import { describe, it, expect } from 'vitest'
import { formatDateFr, formatWeightKg, formatPlate } from '../weighingTicketFormat'
describe('weighingTicketFormat', () => {
// ── Date JJ-MM-AAAA ───────────────────────────────────────────────────────
describe('formatDateFr', () => {
it('formate un datetime ISO en JJ-MM-AAAA', () => {
expect(formatDateFr('2026-06-17T09:12:00+02:00')).toBe('17-06-2026')
})
it('zéro-pad le jour et le mois', () => {
expect(formatDateFr('2026-01-05T00:00:00Z')).toBe('05-01-2026')
})
it('retourne une chaîne vide si absente ou invalide', () => {
expect(formatDateFr(null)).toBe('')
expect(formatDateFr(undefined)).toBe('')
expect(formatDateFr('pas-une-date')).toBe('')
})
})
// ── Poids « X XXX Kg » ────────────────────────────────────────────────────
describe('formatWeightKg', () => {
it('ajoute un séparateur de milliers (espace) et le suffixe Kg', () => {
expect(formatWeightKg(7150)).toBe('7 150 Kg')
expect(formatWeightKg(14300)).toBe('14 300 Kg')
expect(formatWeightKg(1000000)).toBe('1 000 000 Kg')
})
it('gère les petits nombres sans séparateur', () => {
expect(formatWeightKg(0)).toBe('0 Kg')
expect(formatWeightKg(999)).toBe('999 Kg')
})
it('retourne une chaîne vide si le poids est absent', () => {
expect(formatWeightKg(null)).toBe('')
expect(formatWeightKg(undefined)).toBe('')
})
})
// ── Immatriculation UPPER ─────────────────────────────────────────────────
describe('formatPlate', () => {
it('met en majuscules et trim', () => {
expect(formatPlate(' ab-123-cd ')).toBe('AB-123-CD')
})
it('retourne une chaîne vide si absente', () => {
expect(formatPlate(null)).toBe('')
expect(formatPlate('')).toBe('')
})
})
})
@@ -1,50 +0,0 @@
import type { MaskInputOptions } from 'maska'
/**
* Masques de saisie du module « Tickets de pesée » (M5). Partagés entre le
* composant de bloc (`WeighingBlock`) et les modales de pesée (écrans Ajouter /
* Modifier). La validation de format reste autoritaire côté serveur (RG-5.01).
*/
/**
* Masque « chiffres uniquement » (longueur libre) — Poids et DSD. Verrouille la
* saisie sur des entiers.
*/
export const NUMERIC_MASK: MaskInputOptions = {
mask: 'D',
tokens: { D: { pattern: /[0-9]/, multiple: true } },
}
/**
* Masque « chiffres, maximum 5 » — SAISIE MANUELLE du poids et du DSD (modale de
* pesée manuelle). Borne la saisie à 5 chiffres (≤ 99999) ; le garde-fou serveur
* (Callback mode MANUAL) reste autoritaire. NE PAS utiliser pour l'AFFICHAGE des
* valeurs (WeighingBlock) : un DSD auto-alloué peut dépasser 5 chiffres.
*/
export const MANUAL_NUMERIC_MASK: MaskInputOptions = {
mask: 'DDDDD',
tokens: { D: { pattern: /[0-9]/ } },
}
/**
* Masque plaque FR SIV `XX-000-XX` : 2 lettres, 3 chiffres, 2 lettres, majuscules
* forcées. Utilisé quand « Tout format » n'est pas coché (RG-5.01).
*/
export const PLATE_MASK: MaskInputOptions = {
mask: 'AA-###-AA',
tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => c.toUpperCase() } },
}
/**
* Masque « Tout format » (RG-5.01) : plaques anciennes / étrangères / engins. On
* autorise lettres, chiffres, espace et tiret, en MAJUSCULES, longueur libre —
* mais on filtre tout le reste (accents, ponctuation, symboles : « &é"'(_ç… »).
* Pattern maska charset du projet (cf. shared/utils/textSanitize) : `preProcess`
* retire d'abord les caractères hors charset (le token `multiple` glouton
* s'arrêterait sinon au 1er invalide), puis le token laisse passer le reste.
*/
export const FREE_PLATE_MASK: MaskInputOptions = {
mask: 'P',
tokens: { P: { pattern: /[A-Z0-9 -]/, multiple: true } },
preProcess: (value: string) => value.toUpperCase().replace(/[^A-Z0-9 -]/g, ''),
}
@@ -1,32 +0,0 @@
/**
* Filtres d'affichage du module « Tickets de pesée » (M5, ERP-191). Helpers PURS
* et testables, partagés par la liste et les écrans. Le serveur reste l'autorité
* de normalisation (spec-front § Règles de formatage) : ces helpers ne font que
* mettre en forme la valeur déjà normalisée renvoyée par l'API.
*/
// Date courte française `JJ-MM-AAAA` (spec M5) : helper partagé inter-modules
// (mutualisé avec les répertoires M1→M4). Re-exporté ici pour les écrans M5.
export { formatDateFr } from '~/shared/utils/date'
/**
* Poids en kg avec séparateur de milliers (espace) + suffixe « Kg »
* (spec-front : « 7 150 Kg »). Chaîne vide si le poids est absent (ticket dont la
* pesée à plein n'est pas finalisée). Groupement manuel (espace ASCII) pour un
* rendu déterministe, indépendant de l'ICU de l'environnement.
*/
export function formatWeightKg(value: number | null | undefined): string {
if (value === null || value === undefined) {
return ''
}
const grouped = String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
return `${grouped} Kg`
}
/**
* Immatriculation en MAJUSCULES (cohérent avec la normalisation serveur RG-5.01 :
* trim + UPPER). Chaîne vide si absente.
*/
export function formatPlate(value: string | null | undefined): string {
return value ? value.trim().toUpperCase() : ''
}
@@ -4,6 +4,7 @@
<div <div
v-if="modelValue" v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="cancel"
> >
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl"> <div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-neutral-900"> <h3 class="text-lg font-semibold text-neutral-900">
@@ -6,8 +6,8 @@
* rollback si la requete PATCH `/api/me/current-site` echoue. * rollback si la requete PATCH `/api/me/current-site` echoue.
* *
* Garantie d'unicite : le flag `switching` bloque les double-clicks * Garantie d'unicite : le flag `switching` bloque les double-clicks
* concurrents. Le state est purge au logout via `onAuthSessionCleared` * concurrents. Le reset explicite est appele au logout
* (declenche par `clearSession()`, cf. `useLogout` et l'intercepteur 401). * (voir `modules/core/pages/logout.vue`).
* *
* Auto-select : aucun. Le backend (`UserRbacProcessor::ensureCurrentSiteConsistency`) * Auto-select : aucun. Le backend (`UserRbacProcessor::ensureCurrentSiteConsistency`)
* garantit deja l'invariant "user avec sites non vide => currentSite non null" * garantit deja l'invariant "user avec sites non vide => currentSite non null"
@@ -30,8 +30,8 @@ const availableSites = ref<Site[]>([])
const switching = ref(false) const switching = ref(false)
// Enregistrement unique au niveau module (singleton) : quand clearSession() // Enregistrement unique au niveau module (singleton) : quand clearSession()
// est appelee (logout volontaire via useLogout, ou intercepteur 401 de useApi), // est appelee par l'intercepteur 401 de useApi, le state local est purgé
// le state local est purgé. // de la meme facon qu'au logout explicite (logout.vue).
onAuthSessionCleared(() => { onAuthSessionCleared(() => {
currentSite.value = null currentSite.value = null
availableSites.value = [] availableSites.value = []
@@ -1,111 +1,102 @@
<template> <template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant <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)]">
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : modal de confirmation cote parent. --> <!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }" v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
@click="$emit('remove')" @click="$emit('remove')"
/> />
</div>
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). --> <!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.siteIris)"
:model-value="model.siteIris" :model-value="model.siteIris"
:options="siteOptions" :options="siteOptions"
:label="t('technique.providers.form.address.sites')" :label="t('technique.providers.form.address.sites')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.sites" :error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('technique.providers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. --> <!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris" :model-value="model.contactIris"
:options="contactOptions" :options="contactOptions"
:max-tags="3"
:label="t('technique.providers.form.address.contacts')" :label="t('technique.providers.form.address.contacts')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
<MalioSelect <MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country" :model-value="model.country"
:options="countryOptions" :options="countryOptions"
:label="t('technique.providers.form.address.country')" :label="t('technique.providers.form.address.country')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode" :model-value="model.postalCode"
:label="t('technique.providers.form.address.postalCode')" :label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. --> <!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect <MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))" v-if="!degraded"
:model-value="model.city" :model-value="model.city"
:options="cityOptions" :options="cityOptions"
:label="t('technique.providers.form.address.city')" :label="t('technique.providers.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="!readonly && !disabled" :required="true"
:error="errors?.city" :error="errors?.city"
@update:model-value="onCityChange" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))" v-else
:model-value="model.city" :model-value="model.city"
:label="t('technique.providers.form.address.city')" :label="t('technique.providers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le <!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). --> texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2"> <div class="col-span-2">
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly && !disabled" v-if="!readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('technique.providers.form.address.street')" :label="t('technique.providers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')" :no-results-text="t('technique.providers.form.address.streetNotFound')"
@@ -118,34 +109,28 @@
:model-value="model.street" :model-value="model.street"
:label="t('technique.providers.form.address.street')" :label="t('technique.providers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1"> <div class="col-span-1">
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')" :label="t('technique.providers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials' import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm' import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque code postal FR : 5 chiffres. // Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####' const POSTAL_CODE_MASK = '#####'
@@ -153,8 +138,8 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{ const props = defineProps<{
/** Brouillon de l'adresse (v-model). */ /** Brouillon de l'adresse (v-model). */
modelValue: ProviderAddressFormDraft modelValue: ProviderAddressFormDraft
/** Titre du bloc (ex: « Adresse 1 »). */ /** Categories autorisees sur une adresse (type PRESTATAIRE). */
title: string categoryOptions: RefOption[]
/** Sites Starseed disponibles. */ /** Sites Starseed disponibles. */
siteOptions: RefOption[] siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */ /** Contacts deja saisis, rattachables a l'adresse. */
@@ -162,13 +147,7 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -214,37 +193,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
// les champs texte editables (complement, ville en mode degrade). La voie en
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
// via Assert\Regex).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void { function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/**
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
* a chaque frappe).
*/
function onCityChange(value: string | number | null): void {
const next = value === null ? null : String(value)
if (next === (props.modelValue.city ?? null)) {
return
}
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
city: next,
street: null,
streetComplement: null,
})
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */ /** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void { function notifyUnavailable(): void {
if (!unavailableNotified) { if (!unavailableNotified) {
@@ -255,27 +208,9 @@ function notifyUnavailable(): void {
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */ /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
async function onPostalCodeChange(value: string): Promise<void> { async function onPostalCodeChange(value: string): Promise<void> {
const digits = (value ?? '').replace(/\D/g, '')
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
if (digits.length === 5 && digits !== previousDigits) {
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
postalCode: value,
city: null,
street: null,
streetComplement: null,
})
}
else {
update('postalCode', value) update('postalCode', value)
}
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) { if (digits.length < 5) {
return return
} }
@@ -1,75 +1,55 @@
<template> <template>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant <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)]">
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc) ou en lecture seule. --> non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly && !disabled" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }" v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
@click="$emit('remove')" @click="$emit('remove')"
/> />
</div>
<!-- Grille 4 colonnes des champs du contact. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.lastName)"
:model-value="model.lastName" :model-value="model.lastName"
:label="t('technique.providers.form.contact.lastName')" :label="t('technique.providers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.firstName)"
:model-value="model.firstName" :model-value="model.firstName"
:label="t('technique.providers.form.contact.firstName')" :label="t('technique.providers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> <div class="col-span-2">
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')" :label="t('technique.providers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
</div> </div>
<MalioInputEmail <MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email" :model-value="model.email"
:label="t('technique.providers.form.contact.email')" :label="t('technique.providers.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
/> />
<MalioInputPhone <MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary" :model-value="model.phonePrimary"
:label="t('technique.providers.form.contact.phonePrimary')" :label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')" :add-button-label="t('technique.providers.form.contact.addPhone')"
@@ -78,23 +58,19 @@
/> />
<!-- 2e numero : revele a la demande (max 2 telephones par contact). --> <!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
<MalioInputPhone <MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))" v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary" :model-value="model.phoneSecondary"
:label="t('technique.providers.form.contact.phoneSecondary')" :label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm' import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur). // Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
@@ -102,18 +78,10 @@ const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{ const props = defineProps<{
/** Brouillon du contact (v-model). */ /** Brouillon du contact (v-model). */
modelValue: ProviderContactFormDraft modelValue: ProviderContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable). */ /** Affiche l'icone de suppression (1er bloc non supprimable). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -128,10 +96,6 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template. // Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void { function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -46,7 +46,7 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
return mount(ProviderAddressBlock, { return mount(ProviderAddressBlock, {
props: { props: {
modelValue: { ...emptyProviderAddress(), ...overrides }, modelValue: { ...emptyProviderAddress(), ...overrides },
title: 'Adresse 1', categoryOptions: [],
siteOptions: [], siteOptions: [],
contactOptions: [], contactOptions: [],
countryOptions: [], countryOptions: [],
@@ -79,14 +79,17 @@ describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/tri
}) })
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => { describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur sur sites (RG-3.05)', () => { it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
const wrapper = mountBlock({}, { const wrapper = mountBlock({}, {
sites: 'Au moins un site est obligatoire.', sites: 'Au moins un site est obligatoire.',
categories: 'Au moins une catégorie est obligatoire.',
}) })
const checkboxes = wrapper.findAll('malio-select-checkbox-stub') const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites') const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.') expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
}) })
it('affiche l\'erreur serveur sur le code postal', () => { it('affiche l\'erreur serveur sur le code postal', () => {
@@ -29,7 +29,6 @@ function mountBlock(errors?: Record<string, string>) {
return mount(ProviderContactBlock, { return mount(ProviderContactBlock, {
props: { props: {
modelValue: emptyProviderContact(), modelValue: emptyProviderContact(),
title: 'Contact 1',
...(errors ? { errors } : {}), ...(errors ? { errors } : {}),
}, },
global: { global: {
@@ -330,16 +330,17 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
return form return form
} }
/** Remplit un bloc adresse valide (site + scalaires requis). */ /** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
function fillValidAddress(form: ProviderForm, index = 0): void { function fillValidAddress(form: ProviderForm, index = 0): void {
const a = addressAt(form, index) const a = addressAt(form, index)
a.siteIris = [SITE_86] a.siteIris = [SITE_86]
a.categoryIris = [CAT_MAINT]
a.postalCode = '86100' a.postalCode = '86100'
a.city = 'Châtellerault' a.city = 'Châtellerault'
a.street = '1 rue du Test' a.street = '1 rue du Test'
} }
it('RG-3.05 : « + Nouvelle adresse » desactive tant que le site manque', () => { it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
const form = createdForm() const form = createdForm()
expect(form.canAddAddress.value).toBe(false) expect(form.canAddAddress.value).toBe(false)
@@ -348,6 +349,8 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
expect(form.addresses.value).toHaveLength(1) expect(form.addresses.value).toHaveLength(1)
addressAt(form).siteIris = [SITE_86] addressAt(form).siteIris = [SITE_86]
expect(form.canAddAddress.value).toBe(false) // categorie manquante
addressAt(form).categoryIris = [CAT_MAINT]
expect(form.canAddAddress.value).toBe(true) expect(form.canAddAddress.value).toBe(true)
form.addAddress() form.addAddress()
expect(form.addresses.value).toHaveLength(2) expect(form.addresses.value).toHaveLength(2)
@@ -374,7 +377,7 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
expect(ok).toBe(true) expect(ok).toBe(true)
const [url, body, opts] = mockPost.mock.calls[0] ?? [] const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/providers/7/addresses') expect(url).toBe('/providers/7/addresses')
expect(body).toMatchObject({ sites: [SITE_86], city: 'Châtellerault' }) expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(addressAt(form).id).toBe(88) expect(addressAt(form).id).toBe(88)
expect(form.isValidated('address')).toBe(true) expect(form.isValidated('address')).toBe(true)
@@ -44,7 +44,7 @@ describe('useProvidersRepository', () => {
expect(mockApiGet).toHaveBeenCalledTimes(1) expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0] const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/providers') expect(url).toBe('/providers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 }) expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
expect(opts).toMatchObject({ expect(opts).toMatchObject({
toast: false, toast: false,
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
@@ -84,11 +84,6 @@ export function useProviderForm() {
}) })
} }
/** Toast de succès après suppression serveur confirmée d'une sous-ressource. */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Etat du prestataire cree ──────────────────────────────────────────── // ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null) const providerId = ref<number | null>(null)
const mainLocked = ref(false) const mainLocked = ref(false)
@@ -344,7 +339,6 @@ export function useProviderForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderContact, makeEmpty: emptyProviderContact,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -423,7 +417,6 @@ export function useProviderForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderAddress, makeEmpty: emptyProviderAddress,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -525,7 +518,6 @@ export function useProviderForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderRib, makeEmpty: emptyProviderRib,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -26,13 +26,6 @@ import { ref } from 'vue'
export interface RefOption { export interface RefOption {
value: string value: string
label: string label: string
// Couleur de fond optionnelle de l'option (hex #RRGGBB). Alimentee par le
// referentiel sites (couleur d'identification du site, affichee sur les tags
// selectionnes du multiselect).
color?: string
// Couleur de texte optionnelle (hex). Sites : blanc, pour rester lisible
// sur le fond colore du tag.
textColor?: string
} }
/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */ /** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */
@@ -57,7 +50,6 @@ interface CategoryMember extends HydraMember {
interface SiteMember extends HydraMember { interface SiteMember extends HydraMember {
name: string name: string
postalCode: string postalCode: string
color?: string
} }
interface CountryMember extends HydraMember { interface CountryMember extends HydraMember {
@@ -102,7 +94,7 @@ export function useProviderReferentials() {
// Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres // Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ». // du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2), color: s.color, textColor: '#FFFFFF' })) }), .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke // Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke
// `country` en chaine libre, « France »...). value === label. Aligne sur // `country` en chaine libre, « France »...). value === label. Aligne sur
// les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse. // les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse.
@@ -59,6 +59,5 @@ export interface Provider {
* `usePaginatedList`. Aucun reset au logout a gerer. * `usePaginatedList`. Aucun reset au logout a gerer.
*/ */
export function useProvidersRepository() { export function useProvidersRepository() {
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193). return usePaginatedList<Provider>({ url: '/providers' })
return usePaginatedList<Provider>({ url: '/providers', defaultItemsPerPage: 25 })
} }
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('technique.providers.edit.back')"
v-bind="{ ariaLabel: t('technique.providers.edit.back') }" v-bind="{ ariaLabel: t('technique.providers.edit.back') }"
@click="goBack" @click="goBack"
/> />
@@ -23,18 +22,16 @@
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')" :label="t('technique.providers.form.main.companyName')"
:mask="FREE_TEXT_MASK"
:required="true" :required="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="referentials.categories.value" :options="referentials.categories.value"
:max-tags="3"
:label="t('technique.providers.form.main.categories')" :label="t('technique.providers.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -44,7 +41,7 @@
:options="referentials.sites.value" :options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')" :label="t('technique.providers.form.main.sites')"
:display-tag="true" :display-tag="true"
:disabled="businessReadonly" :readonly="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.sites" :error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
@@ -73,10 +70,8 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1" :readonly="businessReadonly"
:disabled="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -107,13 +102,12 @@
v-for="(address, index) in addresses" v-for="(address, index) in addresses"
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('technique.providers.form.address.title', { n: index + 1 })" :category-options="referentials.categories.value"
:last="index === addresses.length - 1"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -141,23 +135,20 @@
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). --> <!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). --> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')" :label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')" :label="t('technique.providers.form.accounting.accountNumber')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -165,7 +156,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')" :label="t('technique.providers.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -174,8 +165,7 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')" :label="t('technique.providers.form.accounting.nTva')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -183,7 +173,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')" :label="t('technique.providers.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -193,7 +183,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')" :label="t('technique.providers.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -204,7 +194,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')" :label="t('technique.providers.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -213,47 +203,39 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :key="index"
class="pb-[20px]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }" v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)" @click="askRemoveRib(index)"
/> />
</div> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')" :label="t('technique.providers.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')" :label="t('technique.providers.form.accounting.ribBic')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')" :label="t('technique.providers.form.accounting.ribIban')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -283,7 +265,7 @@
</template> </template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). --> <!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md"> <MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2> <h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
</template> </template>
@@ -331,7 +313,6 @@ import {
} from '~/modules/technique/types/providerForm' } from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow' import { isRowRemovable } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). // Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('technique.providers.consultation.back')"
v-bind="{ ariaLabel: t('technique.providers.consultation.back') }" v-bind="{ ariaLabel: t('technique.providers.consultation.back') }"
@click="goBack" @click="goBack"
/> />
@@ -23,7 +22,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="danger" variant="secondary"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('technique.providers.action.archive')" :label="t('technique.providers.action.archive')"
@@ -48,33 +47,28 @@
<!-- Bloc principal (lecture seule) --> <!-- Bloc principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(provider.companyName)"
:model-value="provider.companyName" :model-value="provider.companyName"
:label="t('technique.providers.form.main.companyName')" :label="t('technique.providers.form.main.companyName')"
disabled readonly
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="isFilled(mainCategoryIris)"
:model-value="mainCategoryIris" :model-value="mainCategoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:max-tags="3"
:label="t('technique.providers.form.main.categories')" :label="t('technique.providers.form.main.categories')"
:display-tag="true" :display-tag="true"
disabled readonly
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
v-if="isFilled(mainSiteIris)"
:model-value="mainSiteIris" :model-value="mainSiteIris"
:options="mainSiteOptions" :options="mainSiteOptions"
:label="t('technique.providers.form.main.sites')" :label="t('technique.providers.form.main.sites')"
:display-tag="true" :display-tag="true"
disabled readonly
/> />
</div> </div>
<!-- Onglets (navigation libre, tout en lecture seule) --> <!-- Onglets (navigation libre, tout en lecture seule) -->
<!-- ERP-193 : barre masquee s'il ne reste aucun onglet non vide. --> <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Contacts --> <!-- Onglet Contacts -->
<template #contacts> <template #contacts>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
@@ -82,10 +76,7 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })" readonly
:last="index === contacts.length - 1"
disabled
hide-empty
/> />
</div> </div>
</template> </template>
@@ -97,49 +88,44 @@
v-for="(view, index) in addressViews" v-for="(view, index) in addressViews"
:key="index" :key="index"
:model-value="view.draft" :model-value="view.draft"
:title="t('technique.providers.form.address.title', { n: index + 1 })" :category-options="view.categoryOptions"
:last="index === addressViews.length - 1"
:site-options="view.siteOptions" :site-options="view.siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptionsFor(view.draft.country)" :country-options="countryOptionsFor(view.draft.country)"
disabled readonly
hide-empty
/> />
</div> </div>
</template> </template>
<!-- ERP-193 : les onglets « a venir » (Rapports / Echanges) ne sont
plus rendus en consultation (masquage des onglets vides). --> <!-- Onglets placeholder « A venir » (comme les autres modules). -->
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- Onglet Comptabilite (present uniquement si accounting.view). --> <!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). --> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2> <MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4"> <MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
<MalioInputText v-if="isFilled(accounting.siren)" :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled /> <MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
<MalioInputText v-if="isFilled(accounting.accountNumber)" :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled /> <MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
<MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" /> <MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" />
<MalioInputText v-if="isFilled(accounting.nTva)" :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" disabled /> <MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" />
<MalioSelect v-if="isFilled(accounting.paymentDelayIri)" :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" disabled empty-option-label="" /> <MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
<MalioSelect v-if="isFilled(accounting.paymentTypeIri)" :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" disabled empty-option-label="" />
<MalioSelect v-if="isBankRequired && isFilled(accounting.bankIri)" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" disabled empty-option-label="" />
</div> </div>
</div> </div>
<!-- Blocs RIB (uniquement si type de reglement = LCR). <!-- Blocs RIB (uniquement si type de reglement = LCR). -->
Titre « RIB N », filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :key="index"
class="pb-[20px]" class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4"> <MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
<MalioInputText v-if="isFilled(rib.label)" :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled /> <MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
<MalioInputText v-if="isFilled(rib.bic)" :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled /> <MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
<MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
</div> </div>
</div> </div>
</div> </div>
@@ -148,7 +134,7 @@
</template> </template>
<!-- Modal de confirmation archivage / restauration. --> <!-- Modal de confirmation archivage / restauration. -->
<MalioModal :dismissable="false" v-model="confirmArchive.open" modal-class="max-w-md"> <MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2> <h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
</template> </template>
@@ -172,7 +158,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useProvider } from '~/modules/technique/composables/useProvider' import { useProvider } from '~/modules/technique/composables/useProvider'
import { import {
canEditProvider, canEditProvider,
@@ -184,7 +170,6 @@ import {
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf, paymentTypeCodeOf,
providerConsultationVisibleTabs,
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
@@ -192,7 +177,6 @@ import {
} from '~/modules/technique/utils/forms/providerDetail' } from '~/modules/technique/utils/forms/providerDetail'
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting' import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm' import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
import { isFilled } from '~/shared/utils/consultationDisplay'
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
@@ -213,6 +197,7 @@ const headerTitle = computed(() => provider.value?.companyName || t('technique.p
useHead({ title: t('technique.providers.consultation.title') }) useHead({ title: t('technique.providers.consultation.title') })
// Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) // Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité)
const activeTab = ref('contacts')
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
contacts: 'mdi:account-box-plus-outline', contacts: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline', address: 'mdi:map-marker-outline',
@@ -220,27 +205,11 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:swap-horizontal', exchanges: 'mdi:swap-horizontal',
accounting: 'mdi:bank-circle-outline', accounting: 'mdi:bank-circle-outline',
} }
// ERP-193 (retour metier) : on masque les coquilles (Rapports / Echanges) ET const tabs = computed(() => {
// tout onglet de donnees vide. La liste depend donc du payload charge. const keys = ['contacts', 'address', 'reports', 'exchanges']
const visibleTabKeys = computed(() => providerConsultationVisibleTabs(provider.value, { if (canAccountingView.value) keys.push('accounting')
canAccountingView: canAccountingView.value, return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
})) })
const tabs = computed(() => visibleTabKeys.value.map(
key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }),
))
// Onglet initial : vide tant que le prestataire n'est pas charge, puis premier
// onglet visible. Un watcher recale si l'onglet courant disparait.
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = keys[0]
}
}, { immediate: true })
// Donnees mappees depuis la SEULE reponse detail // Donnees mappees depuis la SEULE reponse detail
const mainCategoryIris = computed(() => irisOf(provider.value?.categories)) const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
@@ -257,15 +226,16 @@ const contacts = computed(() => {
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses). // Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts)) const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
// Vue par adresse : brouillon + options propres a l'adresse (sites embarques). // Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
const addressViews = computed(() => { const addressViews = computed(() => {
const views = (provider.value?.addresses ?? []).map(address => ({ const views = (provider.value?.addresses ?? []).map(address => ({
draft: mapAddressToDraft(address), draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites), siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
})) }))
return views.length > 0 return views.length > 0
? views ? views
: [{ draft: emptyProviderAddress(), siteOptions: [] }] : [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
}) })
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */ /** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
@@ -44,7 +44,7 @@
@update:page="goToPage" @update:page="goToPage"
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). --> <!-- Categories : libelles (name) separes par une virgule. -->
<template #cell-categories="{ item }"> <template #cell-categories="{ item }">
{{ formatCategories(item) }} {{ formatCategories(item) }}
</template> </template>
@@ -63,9 +63,10 @@
</span> </span>
</template> </template>
<!-- Derniere activite : volontairement vide tant que le suivi <!-- Derniere activite : date de derniere modification (updatedAt), format JJ-MM-AAAA. -->
d'activite (onglets de la fiche) n'est pas encore developpe. --> <template #cell-lastActivity="{ item }">
<template #cell-lastActivity /> {{ formatLastActivity(item) }}
</template>
</MalioDataTable> </MalioDataTable>
<div class="flex justify-center mt-4"> <div class="flex justify-center mt-4">
@@ -199,6 +200,7 @@ const rows = computed(() => providers.value.map(provider => ({
companyName: provider.companyName, companyName: provider.companyName,
categories: provider.categories, categories: provider.categories,
sites: provider.sites, sites: provider.sites,
updatedAt: provider.updatedAt,
}))) })))
const columns = [ const columns = [
@@ -208,12 +210,35 @@ const columns = [
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') }, { key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
] ]
/** Libelles (name) des categories du prestataire, separes par une virgule (aligne sur le client, ERP-193). */ /** Libelles des categories du prestataire, separes par une virgule (name). */
function formatCategories(item: Record<string, unknown>): string { function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Provider['categories']) ?? [] const categories = (item.categories as Provider['categories']) ?? []
return categories.map(c => c.name).join(', ') return categories.map(c => c.name).join(', ')
} }
/**
* Derniere activite : date de derniere modification de la fiche (updatedAt,
* expose en liste via default:read). Format court francais JJ-MM-AAAA (tirets,
* cf. spec-front M3 § Datatable).
*/
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 ''
}
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = date.getFullYear()
return `${day}-${month}-${year}`
}
/** Clic sur une ligne → ecran Consultation (route a plat /providers/{id}). */ /** Clic sur une ligne → ecran Consultation (route a plat /providers/{id}). */
function onRowClick(item: Record<string, unknown>): void { function onRowClick(item: Record<string, unknown>): void {
router.push(`/providers/${item.id}`) router.push(`/providers/${item.id}`)
@@ -6,7 +6,6 @@
icon="mdi:arrow-left-bold" icon="mdi:arrow-left-bold"
icon-size="24" icon-size="24"
variant="ghost" variant="ghost"
:title="t('technique.providers.form.back')"
v-bind="{ ariaLabel: t('technique.providers.form.back') }" v-bind="{ ariaLabel: t('technique.providers.form.back') }"
@click="goBack" @click="goBack"
/> />
@@ -22,18 +21,16 @@
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')" :label="t('technique.providers.form.main.companyName')"
:mask="FREE_TEXT_MASK"
:required="true" :required="true"
:disabled="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="referentials.categories.value" :options="referentials.categories.value"
:max-tags="3"
:label="t('technique.providers.form.main.categories')" :label="t('technique.providers.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -43,7 +40,7 @@
:options="referentials.sites.value" :options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')" :label="t('technique.providers.form.main.sites')"
:display-tag="true" :display-tag="true"
:disabled="mainLocked" :readonly="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.sites" :error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
@@ -74,19 +71,13 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1" :readonly="isValidated('contact')"
:disabled="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
<!-- Masque tant que le prestataire n'est pas cree : Contact etant <div v-if="!isValidated('contact')" class="flex justify-center gap-6">
l'onglet actif par defaut, ses actions (Ajouter / Valider) ne
doivent pas apparaitre a cote du Valider du formulaire principal
(ERP-193). -->
<div v-if="!isValidated('contact') && providerId !== null" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
@@ -98,7 +89,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('technique.providers.form.submit')" :label="t('technique.providers.form.submit')"
:disabled="tabSubmitting" :disabled="tabSubmitting || providerId === null"
@click="onSubmitContacts" @click="onSubmitContacts"
/> />
</div> </div>
@@ -111,13 +102,12 @@
v-for="(address, index) in addresses" v-for="(address, index) in addresses"
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('technique.providers.form.address.title', { n: index + 1 })" :category-options="referentials.categories.value"
:last="index === addresses.length - 1"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:disabled="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -144,23 +134,20 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). --> <!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). --> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')" :label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')" :label="t('technique.providers.form.accounting.accountNumber')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -168,7 +155,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')" :label="t('technique.providers.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -177,8 +164,7 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')" :label="t('technique.providers.form.accounting.nTva')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -186,7 +172,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')" :label="t('technique.providers.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -196,7 +182,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')" :label="t('technique.providers.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -208,7 +194,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')" :label="t('technique.providers.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -217,47 +203,39 @@
</div> </div>
</div> </div>
<!-- Blocs RIB affiches uniquement si type de reglement = LCR (RG-3.08). <!-- Blocs RIB affiches uniquement si type de reglement = LCR (RG-3.08). -->
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :key="index"
class="pb-[20px]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="p-0" button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }" v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)" @click="askRemoveRib(index)"
/> />
</div> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')" :label="t('technique.providers.form.accounting.ribLabel')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')" :label="t('technique.providers.form.accounting.ribBic')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')" :label="t('technique.providers.form.accounting.ribIban')"
:mask="CODE_ALNUM_MASK" :readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -286,7 +264,7 @@
</MalioTabList> </MalioTabList>
<!-- Modal de confirmation generique (suppression d'un bloc contact). --> <!-- Modal de confirmation generique (suppression d'un bloc contact). -->
<MalioModal :dismissable="false" v-model="confirmModal.open" modal-class="max-w-md"> <MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header> <template #header>
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2> <h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
</template> </template>
@@ -319,7 +297,6 @@ import {
} from '~/modules/technique/utils/forms/providerAccounting' } from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow' import { isRowRemovable } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). // Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -97,6 +97,8 @@ export interface ProviderAddressFormDraft {
city: string | null city: string | null
street: string | null street: string | null
streetComplement: string | null streetComplement: string | null
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
categoryIris: string[]
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */ /** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
siteIris: string[] siteIris: string[]
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */ /** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
@@ -112,6 +114,7 @@ export function emptyProviderAddress(): ProviderAddressFormDraft {
city: null, city: null,
street: null, street: null,
streetComplement: null, streetComplement: null,
categoryIris: [],
siteIris: [], siteIris: [],
contactIris: [], contactIris: [],
} }
@@ -12,15 +12,21 @@ import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
*/ */
describe('providerAddress helpers', () => { describe('providerAddress helpers', () => {
const SITE = '/api/sites/1' const SITE = '/api/sites/1'
const CAT = '/api/categories/7'
describe('isProviderAddressValid (RG-3.05)', () => { describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
it('false sans site', () => { it('false sans site', () => {
const address = { ...emptyProviderAddress() } const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
expect(isProviderAddressValid(address)).toBe(false) expect(isProviderAddressValid(address)).toBe(false)
}) })
it('true avec au moins un site', () => { it('false sans categorie', () => {
const address = { ...emptyProviderAddress(), siteIris: [SITE] } const address = { ...emptyProviderAddress(), siteIris: [SITE] }
expect(isProviderAddressValid(address)).toBe(false)
})
it('true avec au moins un site ET une categorie', () => {
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
expect(isProviderAddressValid(address)).toBe(true) expect(isProviderAddressValid(address)).toBe(true)
}) })
}) })
@@ -33,6 +39,7 @@ describe('providerAddress helpers', () => {
city: 'Châtellerault', city: 'Châtellerault',
street: '1 rue du Test', street: '1 rue du Test',
siteIris: [SITE], siteIris: [SITE],
categoryIris: [CAT],
contactIris: ['/api/provider_contacts/9'], contactIris: ['/api/provider_contacts/9'],
}) })
expect(payload).toEqual({ expect(payload).toEqual({
@@ -41,6 +48,7 @@ describe('providerAddress helpers', () => {
city: 'Châtellerault', city: 'Châtellerault',
street: '1 rue du Test', street: '1 rue du Test',
streetComplement: null, streetComplement: null,
categories: [CAT],
sites: [SITE], sites: [SITE],
contacts: ['/api/provider_contacts/9'], contacts: ['/api/provider_contacts/9'],
}) })
@@ -53,6 +61,7 @@ describe('providerAddress helpers', () => {
const payload = buildProviderAddressPayload({ const payload = buildProviderAddressPayload({
...emptyProviderAddress(), ...emptyProviderAddress(),
siteIris: [SITE], siteIris: [SITE],
categoryIris: [CAT],
}) })
expect(payload).not.toHaveProperty('postalCode') expect(payload).not.toHaveProperty('postalCode')
expect(payload).not.toHaveProperty('city') expect(payload).not.toHaveProperty('city')
@@ -10,7 +10,6 @@ const {
canEditProvider, canEditProvider,
categoryOptionsOf, categoryOptionsOf,
contactOptionsOf, contactOptionsOf,
hasAccountingData,
iriOf, iriOf,
irisOf, irisOf,
mapAccountingDraft, mapAccountingDraft,
@@ -18,7 +17,6 @@ const {
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf, paymentTypeCodeOf,
providerConsultationVisibleTabs,
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
@@ -76,7 +74,7 @@ describe('providerDetail helpers', () => {
}) })
describe('mapAddressToDraft', () => { describe('mapAddressToDraft', () => {
it('extrait les IRI des sites / contacts embarques', () => { it('extrait les IRI des sites / categories / contacts embarques', () => {
const draft = mapAddressToDraft({ const draft = mapAddressToDraft({
'@id': '/api/provider_addresses/3', '@id': '/api/provider_addresses/3',
id: 3, id: 3,
@@ -85,9 +83,11 @@ describe('providerDetail helpers', () => {
city: 'Châtellerault', city: 'Châtellerault',
street: '1 rue du Test', street: '1 rue du Test',
sites: [{ '@id': '/api/sites/1' }], sites: [{ '@id': '/api/sites/1' }],
categories: [{ '@id': '/api/categories/7' }],
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'], contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
}) })
expect(draft.siteIris).toEqual(['/api/sites/1']) expect(draft.siteIris).toEqual(['/api/sites/1'])
expect(draft.categoryIris).toEqual(['/api/categories/7'])
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6']) expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
expect(draft.id).toBe(3) expect(draft.id).toBe(3)
}) })
@@ -122,8 +122,8 @@ describe('providerDetail helpers', () => {
it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => { it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }])) expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }]))
.toEqual([{ value: '/api/categories/7', label: 'Maintenance' }]) .toEqual([{ value: '/api/categories/7', label: 'Maintenance' }])
expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault', color: '#000' }])) expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }]))
.toEqual([{ value: '/api/sites/1', label: 'Châtellerault', color: '#000', textColor: '#FFFFFF' }]) .toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }])
expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }])) expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }]))
.toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }]) .toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }])
}) })
@@ -165,48 +165,3 @@ describe('providerDetail helpers', () => {
}) })
}) })
}) })
describe('hasAccountingData (prestataire)', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable ou un RIB', () => {
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1, siren: '123456789' })).toBe(true)
expect(hasAccountingData({
'@id': '/api/providers/1', id: 1,
ribs: [{ '@id': '/api/provider_ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('providerConsultationVisibleTabs', () => {
it('retourne [] tant que le prestataire n\'est pas charge', () => {
expect(providerConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles (reports/exchanges) et les onglets vides', () => {
expect(providerConsultationVisibleTabs(
{ '@id': '/api/providers/1', id: 1, companyName: 'ACME' },
{ canAccountingView: true },
)).toEqual([])
})
it('affiche contacts/address/accounting dans l\'ordre (pas d\'onglet information)', () => {
const provider = {
'@id': '/api/providers/1', id: 1,
contacts: [{ '@id': '/api/provider_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/provider_addresses/1', id: 1 }],
siren: '123456789',
}
expect(providerConsultationVisibleTabs(provider, { canAccountingView: true }))
.toEqual(['contacts', 'address', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view', () => {
expect(providerConsultationVisibleTabs(
{ '@id': '/api/providers/1', id: 1, siren: '123456789' },
{ canAccountingView: false },
)).toEqual([])
})
})
@@ -14,12 +14,12 @@ import type { ProviderAddressFormDraft } from '~/modules/technique/types/provide
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
/** /**
* RG-3.05 : une adresse est « valide » pour autoriser l'ajout d'un nouveau bloc * RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
* des qu'elle porte au moins un site. Les scalaires (CP/ville/rue) restent valides * nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
* par le back (422 inline). * scalaires (CP/ville/rue) restent valides par le back (422 inline).
*/ */
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean { export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
return address.siteIris.length >= 1 return address.siteIris.length >= 1 && address.categoryIris.length >= 1
} }
/** /**
@@ -34,6 +34,7 @@ export function buildProviderAddressPayload(address: ProviderAddressFormDraft):
city: address.city || null, city: address.city || null,
street: address.street || null, street: address.street || null,
streetComplement: address.streetComplement || null, streetComplement: address.streetComplement || null,
categories: [...address.categoryIris],
sites: [...address.siteIris], sites: [...address.siteIris],
contacts: [...address.contactIris], contacts: [...address.contactIris],
} }
@@ -68,6 +68,7 @@ export interface AddressRead extends HydraRef {
street?: string | null street?: string | null
streetComplement?: string | null streetComplement?: string | null
sites?: SiteRead[] sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu. // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
contacts?: Array<HydraRef | string> contacts?: Array<HydraRef | string>
} }
@@ -145,6 +146,7 @@ export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraf
city: address.city ?? null, city: address.city ?? null,
street: address.street ?? null, street: address.street ?? null,
streetComplement: address.streetComplement ?? null, streetComplement: address.streetComplement ?? null,
categoryIris: (address.categories ?? []).map(c => c['@id']),
siteIris: (address.sites ?? []).map(s => s['@id']), siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])), contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
} }
@@ -187,7 +189,7 @@ export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOp
/** Options de sites (value=IRI, label=nom) construites depuis un embed. */ /** Options de sites (value=IRI, label=nom) construites depuis un embed. */
export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] { export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'], color: s.color, textColor: '#FFFFFF' })) 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 prestataire. */ /** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */
@@ -222,58 +224,6 @@ export function paymentTypeCodeOf(relation: Relation): string | null {
return (relation.code as string | undefined) ?? null return (relation.code as string | undefined) ?? null
} }
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert au predicat « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(provider: ProviderDetail): boolean {
const draft = mapAccountingDraft(provider)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (provider.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Rapports / Echanges) ET tout onglet de donnees
* vide. Le prestataire n'a pas d'onglet Information (bloc principal au-dessus
* des onglets). Ordre : Contacts · Adresse · Comptabilite. Retourne `[]` tant
* que le prestataire n'est pas charge.
*/
export function providerConsultationVisibleTabs(
provider: ProviderDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!provider) {
return []
}
const visible: string[] = []
if ((provider.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((provider.addresses ?? []).length > 0) {
visible.push('address')
}
if (options.canAccountingView && hasAccountingData(provider)) {
visible.push('accounting')
}
return visible
}
/** /**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir * `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
@@ -1,83 +1,65 @@
<template> <template>
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. --> <!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. -->
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant <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)]">
(pas de bordure sous le dernier bloc). -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc, en noir (adresse unique, sans suppression). -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
</div>
<!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Pays : prerempli « France » (RG-4.05). --> <!-- Pays : prerempli « France » (RG-4.05). -->
<MalioSelect <MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country" :model-value="model.country"
:options="countryOptions" :options="countryOptions"
:label="t('transport.carriers.form.address.country')" :label="t('transport.carriers.form.address.country')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.country" :error="errors?.country"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). --> <!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode" :model-value="model.postalCode"
:label="t('transport.carriers.form.address.postalCode')" :label="t('transport.carriers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. --> <!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect <MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))" v-if="!degraded"
:model-value="model.city" :model-value="model.city"
:options="cityOptions" :options="cityOptions"
:label="t('transport.carriers.form.address.city')" :label="t('transport.carriers.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="!readonly && !disabled" :required="true"
:error="errors?.city" :error="errors?.city"
@update:model-value="onCityChange" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))" v-else
:model-value="model.city" :model-value="model.city"
:label="t('transport.carriers.form.address.city')" :label="t('transport.carriers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en <!-- Filler : aligne le debut de la ligne suivante sur la grille. -->
consultation masquee (la grille se recompose sans les champs vides). --> <div aria-hidden="true" />
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le <!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). --> texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2"> <div class="col-span-2">
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly && !disabled" v-if="!readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('transport.carriers.form.address.street')" :label="t('transport.carriers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('transport.carriers.form.address.streetNotFound')" :no-results-text="t('transport.carriers.form.address.streetNotFound')"
@@ -90,32 +72,25 @@
:model-value="model.street" :model-value="model.street"
:label="t('transport.carriers.form.address.street')" :label="t('transport.carriers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :required="true"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
<MalioInputText <MalioInputText
v-if="!hideEmpty || isFilled(model.streetComplement)"
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('transport.carriers.form.address.streetComplement')" :label="t('transport.carriers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm' import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
import { isFilled } from '~/shared/utils/consultationDisplay'
interface RefOption { interface RefOption {
value: string value: string
@@ -128,17 +103,9 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{ const props = defineProps<{
/** Brouillon de l'adresse (v-model). */ /** Brouillon de l'adresse (v-model). */
modelValue: CarrierAddressFormDraft modelValue: CarrierAddressFormDraft
/** Titre du bloc (ex: « Adresse 1 »). */
title: string
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Consultation : masque les champs non remplis (ERP-193). */
hideEmpty?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -183,36 +150,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur les
// champs texte editables (complement, ville en mode degrade). La voie en autocomplete
// (BAN) et la ville en select ne sont pas masquees (le back valide via Assert\Regex).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void { function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
} }
/**
* Selection d'une ville (select assiste BAN) vide adresse + complement, devenus
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
* a chaque frappe).
*/
function onCityChange(value: string | number | null): void {
const next = value === null ? null : String(value)
if (next === (props.modelValue.city ?? null)) {
return
}
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
city: next,
street: null,
streetComplement: null,
})
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */ /** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void { function notifyUnavailable(): void {
if (!unavailableNotified) { if (!unavailableNotified) {
@@ -223,27 +165,9 @@ function notifyUnavailable(): void {
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */ /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> { async function onPostalCodeChange(value: string): Promise<void> {
const digits = (value ?? '').replace(/\D/g, '')
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
// CP complet (5 chiffres) et reellement modifie ville, adresse et complement
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
if (digits.length === 5 && digits !== previousDigits) {
banAddressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
postalCode: value,
city: null,
street: null,
streetComplement: null,
})
}
else {
update('postalCode', value) update('postalCode', value)
}
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) { if (digits.length < 5) {
return return
} }

Some files were not shown because too many files have changed in this diff Show More