Compare commits
102 Commits
v1.9.13
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27d51ffdb1 | ||
|
|
53d4d5768b | ||
|
|
3ff89d43ed | ||
|
|
5c55441e6c | ||
|
|
e432153083 | ||
|
|
b16b619fc9 | ||
|
|
c88333b052 | ||
| 8f5cd98b82 | |||
| 48f7e4c6ac | |||
| c46769a67d | |||
|
|
28394ce1b4 | ||
|
|
8cfcb41a39 | ||
|
|
980a7c310e | ||
|
|
00f18d1c7d | ||
|
|
6e2c5179a9 | ||
| 3cd18a721a | |||
|
|
191e071957 | ||
| f964df76b9 | |||
|
|
6744542f84 | ||
| 3e0e9d5270 | |||
|
|
4e0efc11ba | ||
| 9fc88df3ff | |||
|
|
041a04f0e9 | ||
| d089cd4873 | |||
|
|
b304cf6684 | ||
| 0fe7f3131e | |||
| a6bbcaf6d1 | |||
| 9f2e1da6ec | |||
|
|
7962576eec | ||
| 7d98c1598c | |||
|
|
4772f057a3 | ||
| 6680423e64 | |||
| 2c2de8bc00 | |||
| 150aceac24 | |||
| 972f30e772 | |||
| 8af68c9628 | |||
| eb68336723 | |||
| eeba229574 | |||
| 4454bbea3d | |||
| 1e40334e11 | |||
| 83c75ecf69 | |||
| b54739f6de | |||
| 82cbeb91a5 | |||
| e70c66e215 | |||
|
|
1c07c96184 | ||
| 122170c3fd | |||
|
|
3f5e4b7f51 | ||
| 0832af86cc | |||
| 44b6e0998c | |||
| c4ed8c8edc | |||
| 6d3cbf9157 | |||
| 464633a288 | |||
| 52e6912a1a | |||
| a9428f6bae | |||
| 201485552a | |||
| cfaf234419 | |||
| 244bfdc3e4 | |||
| 8a841832b2 | |||
| 6b8422fd03 | |||
| 7c2ad165e4 | |||
| eef4b01d74 | |||
| 3a5860c83c | |||
| ef4e208828 | |||
| 14ed38704f | |||
| 8b02f821d3 | |||
| 4afbc8ba8a | |||
| b484a426e0 | |||
| 5b06e2ba51 | |||
| 7f91b30bf6 | |||
| 8e0e3a3b33 | |||
| fea51fb66b | |||
| 644b05c30a | |||
| 48beff753e | |||
| db6fd8f36a | |||
| 6a43f08df8 | |||
| 8a355aad11 | |||
| 72c10ced40 | |||
| 71cf131e56 | |||
| 5b37404b9e | |||
| c6e1fce313 | |||
| 63104dc155 | |||
| 2b96d20d56 | |||
| a8a3facec8 | |||
| 54b3b03611 | |||
| 6742da2fce | |||
| 1963ce261d | |||
| a610284325 | |||
| 239f417a35 | |||
| 4f13f7d301 | |||
| 6716d31126 | |||
| 2b04860ea8 | |||
| 894d522036 | |||
| f2eff89e00 | |||
| 1348fa9963 | |||
| 875a34f169 | |||
| 353d7e938e | |||
|
|
a6ca909a73 | ||
| 2c1ddb2126 | |||
|
|
c64b125047 | ||
| 85c7c97dc3 | |||
|
|
1705a3688b | ||
|
|
34b36f5d14 |
@@ -4,7 +4,7 @@
|
|||||||
.env.test
|
.env.test
|
||||||
infra/dev/
|
infra/dev/
|
||||||
infra/prod/docker-compose.yml
|
infra/prod/docker-compose.yml
|
||||||
infra/prod/deploy.sh
|
infra/prod/deploy.sh.example
|
||||||
infra/prod/.env.example
|
infra/prod/.env.example
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
frontend/.nuxt
|
frontend/.nuxt
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
"X-Profile-Id": "admin-default-profile",
|
"X-Profile-Id": "admin-default-profile",
|
||||||
"X-Profile-Password": "A123"
|
"X-Profile-Password": "A123"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"lesstime": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://project.malio-dev.fr/_mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer b355c6cbf27d2a86d7eba1c3132c99bb3133f94cfd9e9243ffcc3c5ae1dc82c8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,3 +264,12 @@ make test-setup # Créer/mettre à jour le schéma test
|
|||||||
- Nuxt dev : `http://localhost:3001`
|
- Nuxt dev : `http://localhost:3001`
|
||||||
- Adminer (PG) : `http://localhost:5050`
|
- Adminer (PG) : `http://localhost:5050`
|
||||||
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
||||||
|
|
||||||
|
## Delegation Codex
|
||||||
|
|
||||||
|
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||||
|
|
||||||
|
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||||
|
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||||
|
|
||||||
|
C'est le meilleur ratio qualite/credits.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/mcp-bundle": "^0.6.0",
|
"symfony/mcp-bundle": "^0.6.0",
|
||||||
|
"symfony/mime": "8.0.*",
|
||||||
|
"symfony/monolog-bundle": "^4.0.2",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
"symfony/rate-limiter": "8.0.*",
|
"symfony/rate-limiter": "8.0.*",
|
||||||
|
|||||||
434
composer.lock
generated
434
composer.lock
generated
@@ -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": "2db01f705a09cf38007a2baa3b078e49",
|
"content-hash": "5c54b1589d9e815f4c9b7e5e1d2d69c7",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -2437,6 +2437,109 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-02-23T21:42:54+00:00"
|
"time": "2026-02-23T21:42:54+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "monolog/monolog",
|
||||||
|
"version": "3.10.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Seldaek/monolog.git",
|
||||||
|
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
||||||
|
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1",
|
||||||
|
"psr/log": "^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"psr/log-implementation": "3.0.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"aws/aws-sdk-php": "^3.0",
|
||||||
|
"doctrine/couchdb": "~1.0@dev",
|
||||||
|
"elasticsearch/elasticsearch": "^7 || ^8",
|
||||||
|
"ext-json": "*",
|
||||||
|
"graylog2/gelf-php": "^1.4.2 || ^2.0",
|
||||||
|
"guzzlehttp/guzzle": "^7.4.5",
|
||||||
|
"guzzlehttp/psr7": "^2.2",
|
||||||
|
"mongodb/mongodb": "^1.8 || ^2.0",
|
||||||
|
"php-amqplib/php-amqplib": "~2.4 || ^3",
|
||||||
|
"php-console/php-console": "^3.1.8",
|
||||||
|
"phpstan/phpstan": "^2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
|
||||||
|
"predis/predis": "^1.1 || ^2",
|
||||||
|
"rollbar/rollbar": "^4.0",
|
||||||
|
"ruflin/elastica": "^7 || ^8",
|
||||||
|
"symfony/mailer": "^5.4 || ^6",
|
||||||
|
"symfony/mime": "^5.4 || ^6"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
|
||||||
|
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
|
||||||
|
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
|
||||||
|
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
|
||||||
|
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
|
||||||
|
"ext-mbstring": "Allow to work properly with unicode symbols",
|
||||||
|
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
|
||||||
|
"ext-openssl": "Required to send log messages using SSL",
|
||||||
|
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
|
||||||
|
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
|
||||||
|
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
|
||||||
|
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
|
||||||
|
"rollbar/rollbar": "Allow sending log messages to Rollbar",
|
||||||
|
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Monolog\\": "src/Monolog"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "https://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
|
||||||
|
"homepage": "https://github.com/Seldaek/monolog",
|
||||||
|
"keywords": [
|
||||||
|
"log",
|
||||||
|
"logging",
|
||||||
|
"psr-3"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Seldaek/monolog/issues",
|
||||||
|
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Seldaek",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-01-02T08:56:05+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "nelmio/cors-bundle",
|
"name": "nelmio/cors-bundle",
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
@@ -5341,6 +5444,248 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-04T16:39:24+00:00"
|
"time": "2026-03-04T16:39:24+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/mime",
|
||||||
|
"version": "v8.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/mime.git",
|
||||||
|
"reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/mime/zipball/ddff21f14c7ce04b98101b399a9463dce8b0ce66",
|
||||||
|
"reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/polyfill-intl-idn": "^1.10",
|
||||||
|
"symfony/polyfill-mbstring": "^1.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"egulias/email-validator": "~3.0.0",
|
||||||
|
"phpdocumentor/reflection-docblock": "<5.2|>=7",
|
||||||
|
"phpdocumentor/type-resolver": "<1.5.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"egulias/email-validator": "^2.1.10|^3.1|^4",
|
||||||
|
"league/html-to-markdown": "^5.0",
|
||||||
|
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
|
||||||
|
"symfony/dependency-injection": "^7.4|^8.0",
|
||||||
|
"symfony/process": "^7.4|^8.0",
|
||||||
|
"symfony/property-access": "^7.4|^8.0",
|
||||||
|
"symfony/property-info": "^7.4|^8.0",
|
||||||
|
"symfony/serializer": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Mime\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Allows manipulating MIME messages",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"mime",
|
||||||
|
"mime-type"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/mime/tree/v8.0.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/monolog-bridge",
|
||||||
|
"version": "v8.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/monolog-bridge.git",
|
||||||
|
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c6efdcbd5cc17cf7618fb4447053b792df6ae724",
|
||||||
|
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"monolog/monolog": "^3",
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/http-kernel": "^7.4|^8.0",
|
||||||
|
"symfony/service-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/console": "^7.4|^8.0",
|
||||||
|
"symfony/http-client": "^7.4|^8.0",
|
||||||
|
"symfony/mailer": "^7.4|^8.0",
|
||||||
|
"symfony/messenger": "^7.4|^8.0",
|
||||||
|
"symfony/mime": "^7.4|^8.0",
|
||||||
|
"symfony/security-core": "^7.4|^8.0",
|
||||||
|
"symfony/var-dumper": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bridge",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Bridge\\Monolog\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides integration for Monolog with various Symfony components",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/monolog-bridge/tree/v8.0.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/monolog-bundle",
|
||||||
|
"version": "v4.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/monolog-bundle.git",
|
||||||
|
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/c012c6aba13129eb02aa7dd61e66e720911d8598",
|
||||||
|
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-runtime-api": "^2.0",
|
||||||
|
"monolog/monolog": "^3.5",
|
||||||
|
"php": ">=8.2",
|
||||||
|
"symfony/config": "^7.3 || ^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.3 || ^8.0",
|
||||||
|
"symfony/http-kernel": "^7.3 || ^8.0",
|
||||||
|
"symfony/monolog-bridge": "^7.3 || ^8.0",
|
||||||
|
"symfony/polyfill-php84": "^1.30"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.5.41 || ^12.3",
|
||||||
|
"symfony/console": "^7.3 || ^8.0",
|
||||||
|
"symfony/yaml": "^7.3 || ^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Bundle\\MonologBundle\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony MonologBundle",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"log",
|
||||||
|
"logging"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/symfony/monolog-bundle/issues",
|
||||||
|
"source": "https://github.com/symfony/monolog-bundle/tree/v4.0.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-02T18:27:21+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/options-resolver",
|
"name": "symfony/options-resolver",
|
||||||
"version": "v8.0.0",
|
"version": "v8.0.0",
|
||||||
@@ -5567,6 +5912,93 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-06-27T09:58:17+00:00"
|
"time": "2025-06-27T09:58:17+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/polyfill-intl-idn",
|
||||||
|
"version": "v1.33.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/polyfill-intl-idn.git",
|
||||||
|
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||||
|
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.2",
|
||||||
|
"symfony/polyfill-intl-normalizer": "^1.10"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-intl": "For best performance"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/polyfill",
|
||||||
|
"name": "symfony/polyfill"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"bootstrap.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Polyfill\\Intl\\Idn\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Laurent Bassin",
|
||||||
|
"email": "laurent@bassin.info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Trevor Rowbotham",
|
||||||
|
"email": "trevor.rowbotham@pm.me"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"compatibility",
|
||||||
|
"idn",
|
||||||
|
"intl",
|
||||||
|
"polyfill",
|
||||||
|
"portable",
|
||||||
|
"shim"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-09-10T14:38:51+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-intl-normalizer",
|
"name": "symfony/polyfill-intl-normalizer",
|
||||||
"version": "v1.33.0",
|
"version": "v1.33.0",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
|||||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
use Symfony\AI\McpBundle\McpBundle;
|
use Symfony\AI\McpBundle\McpBundle;
|
||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
|
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||||
|
|
||||||
@@ -22,4 +23,5 @@ return [
|
|||||||
ApiPlatformBundle::class => ['all' => true],
|
ApiPlatformBundle::class => ['all' => true],
|
||||||
DAMADoctrineTestBundle::class => ['test' => true],
|
DAMADoctrineTestBundle::class => ['test' => true],
|
||||||
McpBundle::class => ['all' => true],
|
McpBundle::class => ['all' => true],
|
||||||
|
MonologBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
56
config/packages/monolog.yaml
Normal file
56
config/packages/monolog.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
monolog:
|
||||||
|
channels:
|
||||||
|
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
|
||||||
|
|
||||||
|
when@dev:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
max_files: 7
|
||||||
|
channels: ["!event"]
|
||||||
|
console:
|
||||||
|
type: console
|
||||||
|
process_psr_3_messages: false
|
||||||
|
channels: ["!event", "!doctrine", "!console"]
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: fingers_crossed
|
||||||
|
action_level: error
|
||||||
|
handler: nested
|
||||||
|
excluded_http_codes: [404, 405]
|
||||||
|
channels: ["!event"]
|
||||||
|
nested:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: fingers_crossed
|
||||||
|
action_level: error
|
||||||
|
handler: nested
|
||||||
|
excluded_http_codes: [404, 405]
|
||||||
|
channels: ["!deprecation"]
|
||||||
|
buffer_size: 50
|
||||||
|
nested:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
max_files: 30
|
||||||
|
console:
|
||||||
|
type: console
|
||||||
|
process_psr_3_messages: false
|
||||||
|
channels: ["!event", "!doctrine"]
|
||||||
|
deprecation:
|
||||||
|
type: rotating_file
|
||||||
|
channels: [deprecation]
|
||||||
|
path: "%kernel.logs_dir%/deprecations.log"
|
||||||
|
max_files: 7
|
||||||
@@ -8,3 +8,15 @@ framework:
|
|||||||
policy: sliding_window
|
policy: sliding_window
|
||||||
limit: 5
|
limit: 5
|
||||||
interval: '1 minute'
|
interval: '1 minute'
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
rate_limiter:
|
||||||
|
mcp_auth:
|
||||||
|
policy: sliding_window
|
||||||
|
limit: 10000
|
||||||
|
interval: '1 minute'
|
||||||
|
login:
|
||||||
|
policy: sliding_window
|
||||||
|
limit: 10000
|
||||||
|
interval: '1 minute'
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '1.9.13'
|
app.version: '1.9.29'
|
||||||
|
|||||||
@@ -0,0 +1,988 @@
|
|||||||
|
# Custom Fields Simplification — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace 3 parallel custom fields frontend implementations (~2900 lines, 9 files) with a single unified module (~400 lines, 2 files), then migrate all consumers.
|
||||||
|
|
||||||
|
**Architecture:** One pure-logic module (`customFields.ts`) with types + helpers, one reactive composable (`useCustomFieldInputs.ts`) wrapping it. The existing API composable `useCustomFields.ts` stays as-is (it's the HTTP layer). The backend already returns a consistent format — only one minor fix needed (add `defaultValue` to serialization groups).
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Vue 3 Composition API, Nuxt 4 auto-imports
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
### New files
|
||||||
|
- `frontend/app/shared/utils/customFields.ts` — types + pure helpers (merge, filter, format, sort)
|
||||||
|
- `frontend/app/composables/useCustomFieldInputs.ts` — reactive composable wrapping pure helpers + API
|
||||||
|
|
||||||
|
### Files to delete (end of migration)
|
||||||
|
- `frontend/app/shared/utils/entityCustomFieldLogic.ts`
|
||||||
|
- `frontend/app/shared/utils/customFieldUtils.ts`
|
||||||
|
- `frontend/app/shared/utils/customFieldFormUtils.ts`
|
||||||
|
- `frontend/app/composables/useEntityCustomFields.ts`
|
||||||
|
|
||||||
|
### Backend file (minor fix)
|
||||||
|
- `src/Entity/CustomField.php` — add `defaultValue` to serialization groups
|
||||||
|
|
||||||
|
### Files to refactor (update imports)
|
||||||
|
- `frontend/app/composables/useComponentEdit.ts`
|
||||||
|
- `frontend/app/composables/useComponentCreate.ts`
|
||||||
|
- `frontend/app/composables/usePieceEdit.ts`
|
||||||
|
- `frontend/app/composables/useMachineDetailCustomFields.ts`
|
||||||
|
- `frontend/app/components/ComponentItem.vue`
|
||||||
|
- `frontend/app/components/PieceItem.vue`
|
||||||
|
- `frontend/app/components/common/CustomFieldDisplay.vue`
|
||||||
|
- `frontend/app/components/common/CustomFieldInputGrid.vue`
|
||||||
|
- `frontend/app/components/machine/MachineCustomFieldsCard.vue`
|
||||||
|
- `frontend/app/components/machine/MachineInfoCard.vue`
|
||||||
|
- `frontend/app/pages/pieces/create.vue`
|
||||||
|
- `frontend/app/pages/product/create.vue`
|
||||||
|
- `frontend/app/pages/product/[id]/edit.vue`
|
||||||
|
- `frontend/app/pages/product/[id]/index.vue`
|
||||||
|
- `frontend/app/shared/model/componentStructure.ts`
|
||||||
|
- `frontend/app/shared/model/componentStructureSanitize.ts`
|
||||||
|
- `frontend/app/shared/model/componentStructureHydrate.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — Add `defaultValue` to serialization groups
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Entity/CustomField.php:62-63`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add Groups attribute to defaultValue**
|
||||||
|
|
||||||
|
In `src/Entity/CustomField.php`, the `defaultValue` property (line 62-63) currently has no `#[Groups]` attribute. Add it so API Platform includes `defaultValue` in all read responses, matching what `MachineStructureController` already returns.
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
|
||||||
|
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||||
|
private ?string $defaultValue = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run linter**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make php-cs-fixer-allow-risky
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/CustomField.php
|
||||||
|
git commit -m "fix(api) : expose defaultValue in custom field serialization groups"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Create unified pure-logic module `customFields.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/app/shared/utils/customFields.ts`
|
||||||
|
|
||||||
|
This replaces all the types and pure functions from `entityCustomFieldLogic.ts` (335 lines), `customFieldUtils.ts` (440 lines), and `customFieldFormUtils.ts` (404 lines).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the types and all pure helper functions**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Unified custom field types and pure helpers.
|
||||||
|
*
|
||||||
|
* Replaces: entityCustomFieldLogic.ts, customFieldUtils.ts, customFieldFormUtils.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A custom field definition (from ModelType structure or CustomField entity) */
|
||||||
|
export interface CustomFieldDefinition {
|
||||||
|
id: string | null
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
required: boolean
|
||||||
|
options: string[]
|
||||||
|
defaultValue: string | null
|
||||||
|
orderIndex: number
|
||||||
|
machineContextOnly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A persisted custom field value (from CustomFieldValue entity via API) */
|
||||||
|
export interface CustomFieldValue {
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
customField: CustomFieldDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merged definition + value for form display and editing */
|
||||||
|
export interface CustomFieldInput {
|
||||||
|
customFieldId: string | null
|
||||||
|
customFieldValueId: string | null
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
required: boolean
|
||||||
|
options: string[]
|
||||||
|
defaultValue: string | null
|
||||||
|
orderIndex: number
|
||||||
|
machineContextOnly: boolean
|
||||||
|
value: string
|
||||||
|
readOnly?: boolean
|
||||||
|
/** options joined by newline — used by category editor textareas (v-model) */
|
||||||
|
optionsText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Normalization — accept any shape, return canonical types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = ['text', 'number', 'select', 'boolean', 'date'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize any raw field definition object into a CustomFieldDefinition.
|
||||||
|
* Handles both standard `{name, type}` and legacy `{key, value: {type}}` formats.
|
||||||
|
*/
|
||||||
|
export function normalizeDefinition(raw: any, fallbackIndex = 0): CustomFieldDefinition | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
|
||||||
|
// Resolve name: standard → legacy key → label
|
||||||
|
const name = (
|
||||||
|
typeof raw.name === 'string' ? raw.name.trim() :
|
||||||
|
typeof raw.key === 'string' ? raw.key.trim() :
|
||||||
|
typeof raw.label === 'string' ? raw.label.trim() :
|
||||||
|
''
|
||||||
|
)
|
||||||
|
if (!name) return null
|
||||||
|
|
||||||
|
// Resolve type: standard → nested in value → fallback
|
||||||
|
const rawType = (
|
||||||
|
typeof raw.type === 'string' ? raw.type :
|
||||||
|
typeof raw.value?.type === 'string' ? raw.value.type :
|
||||||
|
'text'
|
||||||
|
).toLowerCase()
|
||||||
|
const type = ALLOWED_TYPES.includes(rawType as any) ? rawType : 'text'
|
||||||
|
|
||||||
|
// Resolve required
|
||||||
|
const required = typeof raw.required === 'boolean' ? raw.required
|
||||||
|
: typeof raw.value?.required === 'boolean' ? raw.value.required
|
||||||
|
: false
|
||||||
|
|
||||||
|
// Resolve options
|
||||||
|
const optionSource = Array.isArray(raw.options) ? raw.options
|
||||||
|
: Array.isArray(raw.value?.options) ? raw.value.options
|
||||||
|
: []
|
||||||
|
const options = optionSource
|
||||||
|
.map((o: any) => typeof o === 'string' ? o.trim() : typeof o?.value === 'string' ? o.value.trim() : String(o ?? '').trim())
|
||||||
|
.filter((o: string) => o.length > 0 && o !== '[object Object]')
|
||||||
|
|
||||||
|
// Resolve defaultValue
|
||||||
|
const dv = raw.defaultValue ?? raw.value?.defaultValue ?? null
|
||||||
|
const defaultValue = dv !== null && dv !== undefined && dv !== '' ? String(dv) : null
|
||||||
|
|
||||||
|
// Resolve orderIndex
|
||||||
|
const orderIndex = typeof raw.orderIndex === 'number' ? raw.orderIndex : fallbackIndex
|
||||||
|
|
||||||
|
// Resolve machineContextOnly
|
||||||
|
const machineContextOnly = !!raw.machineContextOnly
|
||||||
|
|
||||||
|
// Resolve id
|
||||||
|
const id = typeof raw.id === 'string' ? raw.id
|
||||||
|
: typeof raw.customFieldId === 'string' ? raw.customFieldId
|
||||||
|
: null
|
||||||
|
|
||||||
|
return { id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw value entry into a CustomFieldValue.
|
||||||
|
* Accepts the API format: `{ id, value, customField: {...} }`
|
||||||
|
*/
|
||||||
|
export function normalizeValue(raw: any): CustomFieldValue | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const cf = raw.customField
|
||||||
|
const definition = normalizeDefinition(cf)
|
||||||
|
if (!definition) return null
|
||||||
|
const id = typeof raw.id === 'string' ? raw.id : ''
|
||||||
|
const value = raw.value !== null && raw.value !== undefined ? String(raw.value) : ''
|
||||||
|
return { id, value, customField: definition }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an array of raw definitions into CustomFieldDefinition[].
|
||||||
|
*/
|
||||||
|
export function normalizeDefinitions(raw: any): CustomFieldDefinition[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw
|
||||||
|
.map((item: any, i: number) => normalizeDefinition(item, i))
|
||||||
|
.filter((d: any): d is CustomFieldDefinition => d !== null)
|
||||||
|
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an array of raw values into CustomFieldValue[].
|
||||||
|
*/
|
||||||
|
export function normalizeValues(raw: any): CustomFieldValue[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw
|
||||||
|
.map((item: any) => normalizeValue(item))
|
||||||
|
.filter((v: any): v is CustomFieldValue => v !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Merge — THE one merge function
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge definitions from a ModelType with persisted values from an entity.
|
||||||
|
* Returns a CustomFieldInput[] ready for form display.
|
||||||
|
*
|
||||||
|
* Match strategy: by customField.id first, then by name (case-sensitive).
|
||||||
|
* When no value exists for a definition, uses defaultValue as initial value.
|
||||||
|
*/
|
||||||
|
export function mergeDefinitionsWithValues(
|
||||||
|
rawDefinitions: any,
|
||||||
|
rawValues: any,
|
||||||
|
): CustomFieldInput[] {
|
||||||
|
const definitions = normalizeDefinitions(rawDefinitions)
|
||||||
|
const values = normalizeValues(rawValues)
|
||||||
|
|
||||||
|
// Build lookup maps for values
|
||||||
|
const valueById = new Map<string, CustomFieldValue>()
|
||||||
|
const valueByName = new Map<string, CustomFieldValue>()
|
||||||
|
for (const v of values) {
|
||||||
|
if (v.customField.id) valueById.set(v.customField.id, v)
|
||||||
|
valueByName.set(v.customField.name, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedValueIds = new Set<string>()
|
||||||
|
const matchedNames = new Set<string>()
|
||||||
|
|
||||||
|
// 1. Map definitions to inputs, matching values
|
||||||
|
const result: CustomFieldInput[] = definitions.map((def) => {
|
||||||
|
const matched = (def.id ? valueById.get(def.id) : undefined) ?? valueByName.get(def.name)
|
||||||
|
|
||||||
|
const optionsText = def.options.length ? def.options.join('\n') : undefined
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
if (matched.id) matchedValueIds.add(matched.id)
|
||||||
|
matchedNames.add(def.name)
|
||||||
|
return {
|
||||||
|
customFieldId: def.id,
|
||||||
|
customFieldValueId: matched.id || null,
|
||||||
|
name: def.name,
|
||||||
|
type: def.type,
|
||||||
|
required: def.required,
|
||||||
|
options: def.options,
|
||||||
|
defaultValue: def.defaultValue,
|
||||||
|
orderIndex: def.orderIndex,
|
||||||
|
machineContextOnly: def.machineContextOnly,
|
||||||
|
value: matched.value,
|
||||||
|
optionsText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No value found — use defaultValue
|
||||||
|
return {
|
||||||
|
customFieldId: def.id,
|
||||||
|
customFieldValueId: null,
|
||||||
|
name: def.name,
|
||||||
|
type: def.type,
|
||||||
|
required: def.required,
|
||||||
|
options: def.options,
|
||||||
|
defaultValue: def.defaultValue,
|
||||||
|
orderIndex: def.orderIndex,
|
||||||
|
machineContextOnly: def.machineContextOnly,
|
||||||
|
value: def.defaultValue ?? '',
|
||||||
|
optionsText,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Add orphan values (have a value but no matching definition)
|
||||||
|
for (const v of values) {
|
||||||
|
if (matchedValueIds.has(v.id)) continue
|
||||||
|
if (matchedNames.has(v.customField.name)) continue
|
||||||
|
|
||||||
|
const orphanOptionsText = v.customField.options.length ? v.customField.options.join('\n') : undefined
|
||||||
|
result.push({
|
||||||
|
customFieldId: v.customField.id,
|
||||||
|
customFieldValueId: v.id || null,
|
||||||
|
name: v.customField.name,
|
||||||
|
type: v.customField.type,
|
||||||
|
required: v.customField.required,
|
||||||
|
options: v.customField.options,
|
||||||
|
defaultValue: v.customField.defaultValue,
|
||||||
|
orderIndex: v.customField.orderIndex,
|
||||||
|
machineContextOnly: v.customField.machineContextOnly,
|
||||||
|
value: v.value,
|
||||||
|
optionsText: orphanOptionsText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filter & sort
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Filter fields by context: standalone hides machineContextOnly, machine shows only machineContextOnly */
|
||||||
|
export function filterByContext(
|
||||||
|
fields: CustomFieldInput[],
|
||||||
|
context: 'standalone' | 'machine',
|
||||||
|
): CustomFieldInput[] {
|
||||||
|
if (context === 'machine') return fields.filter((f) => f.machineContextOnly)
|
||||||
|
return fields.filter((f) => !f.machineContextOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort fields by orderIndex */
|
||||||
|
export function sortByOrder(fields: CustomFieldInput[]): CustomFieldInput[] {
|
||||||
|
return [...fields].sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Display helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Format a field value for display (e.g. boolean → Oui/Non) */
|
||||||
|
export function formatValueForDisplay(field: CustomFieldInput): string {
|
||||||
|
const raw = field.value ?? ''
|
||||||
|
if (field.type === 'boolean') {
|
||||||
|
const normalized = String(raw).toLowerCase()
|
||||||
|
if (normalized === 'true' || normalized === '1') return 'Oui'
|
||||||
|
if (normalized === 'false' || normalized === '0') return 'Non'
|
||||||
|
}
|
||||||
|
return raw || 'Non défini'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a field has a displayable value (readOnly fields always display) */
|
||||||
|
export function hasDisplayableValue(field: CustomFieldInput): boolean {
|
||||||
|
if (field.readOnly) return true
|
||||||
|
if (field.type === 'boolean') return field.value !== undefined && field.value !== null && field.value !== ''
|
||||||
|
return typeof field.value === 'string' && field.value.trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable key for v-for rendering */
|
||||||
|
export function fieldKey(field: CustomFieldInput, index: number): string {
|
||||||
|
return field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Persistence helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Whether a field should be persisted (non-empty value) */
|
||||||
|
export function shouldPersist(field: CustomFieldInput): boolean {
|
||||||
|
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
|
||||||
|
return typeof field.value === 'string' && field.value.trim() !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format value for save (trim, boolean coercion) */
|
||||||
|
export function formatValueForSave(field: CustomFieldInput): string {
|
||||||
|
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
|
||||||
|
return typeof field.value === 'string' ? field.value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if all required fields are filled */
|
||||||
|
export function requiredFieldsFilled(fields: CustomFieldInput[]): boolean {
|
||||||
|
return fields.every((field) => {
|
||||||
|
if (!field.required) return true
|
||||||
|
return shouldPersist(field)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/shared/utils/customFields.ts
|
||||||
|
git commit -m "feat(custom-fields) : add unified pure-logic custom fields module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Create unified composable `useCustomFieldInputs.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/app/composables/useCustomFieldInputs.ts`
|
||||||
|
|
||||||
|
This replaces `useEntityCustomFields.ts` (181 lines) and the custom field parts of `useMachineDetailCustomFields.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the composable**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Unified reactive custom field management composable.
|
||||||
|
*
|
||||||
|
* Replaces: useEntityCustomFields.ts, custom field parts of useMachineDetailCustomFields.ts,
|
||||||
|
* and inline custom field logic in useComponentEdit/useComponentCreate/usePieceEdit.
|
||||||
|
*
|
||||||
|
* DESIGN NOTE: Uses an internal mutable `ref` (not a `computed`) so that
|
||||||
|
* save operations can update `customFieldValueId` in place without being
|
||||||
|
* overwritten on the next reactivity cycle. Call `refresh()` to re-merge
|
||||||
|
* from the source definitions + values (e.g. after fetching fresh data).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, watch, computed, type MaybeRef, toValue } from 'vue'
|
||||||
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import {
|
||||||
|
mergeDefinitionsWithValues,
|
||||||
|
filterByContext,
|
||||||
|
formatValueForSave,
|
||||||
|
shouldPersist,
|
||||||
|
requiredFieldsFilled,
|
||||||
|
type CustomFieldDefinition,
|
||||||
|
type CustomFieldValue,
|
||||||
|
type CustomFieldInput,
|
||||||
|
} from '~/shared/utils/customFields'
|
||||||
|
|
||||||
|
export type { CustomFieldDefinition, CustomFieldValue, CustomFieldInput }
|
||||||
|
|
||||||
|
export type CustomFieldEntityType =
|
||||||
|
| 'machine'
|
||||||
|
| 'composant'
|
||||||
|
| 'piece'
|
||||||
|
| 'product'
|
||||||
|
| 'machineComponentLink'
|
||||||
|
| 'machinePieceLink'
|
||||||
|
|
||||||
|
export interface UseCustomFieldInputsOptions {
|
||||||
|
/** Custom field definitions (from ModelType structure or machine.customFields) */
|
||||||
|
definitions: MaybeRef<any[]>
|
||||||
|
/** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */
|
||||||
|
values: MaybeRef<any[]>
|
||||||
|
/** Entity type for API upsert calls */
|
||||||
|
entityType: CustomFieldEntityType
|
||||||
|
/** Entity ID for API upsert calls */
|
||||||
|
entityId: MaybeRef<string | null>
|
||||||
|
/** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */
|
||||||
|
context?: 'standalone' | 'machine'
|
||||||
|
/** Optional callback to update the source values array after a save (keeps parent reactive state in sync) */
|
||||||
|
onValueCreated?: (newValue: { id: string; value: string; customField: any }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
|
||||||
|
const { entityType, context } = options
|
||||||
|
const {
|
||||||
|
updateCustomFieldValue: updateApi,
|
||||||
|
upsertCustomFieldValue,
|
||||||
|
} = useCustomFields()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
// Internal mutable state — NOT a computed, so save can mutate in place
|
||||||
|
const _allFields = ref<CustomFieldInput[]>([])
|
||||||
|
|
||||||
|
// Re-merge from source definitions + values
|
||||||
|
const refresh = () => {
|
||||||
|
const defs = toValue(options.definitions)
|
||||||
|
const vals = toValue(options.values)
|
||||||
|
_allFields.value = mergeDefinitionsWithValues(defs, vals)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh when reactive sources change
|
||||||
|
watch(
|
||||||
|
() => [toValue(options.definitions), toValue(options.values)],
|
||||||
|
() => refresh(),
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filtered by context (standalone vs machine)
|
||||||
|
const fields = computed<CustomFieldInput[]>(() => {
|
||||||
|
if (!context) return _allFields.value
|
||||||
|
return filterByContext(_allFields.value, context)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const requiredFilled = computed(() => requiredFieldsFilled(fields.value))
|
||||||
|
|
||||||
|
// Build metadata for upsert when no customFieldId is available (legacy fallback)
|
||||||
|
const _buildMetadata = (field: CustomFieldInput) => ({
|
||||||
|
customFieldName: field.name,
|
||||||
|
customFieldType: field.type,
|
||||||
|
customFieldRequired: field.required,
|
||||||
|
customFieldOptions: field.options,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update a single field value
|
||||||
|
const update = async (field: CustomFieldInput): Promise<boolean> => {
|
||||||
|
const id = toValue(options.entityId)
|
||||||
|
if (!id) {
|
||||||
|
showError(`Impossible de sauvegarder le champ "${field.name}"`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = formatValueForSave(field)
|
||||||
|
|
||||||
|
// Update existing value
|
||||||
|
if (field.customFieldValueId) {
|
||||||
|
const result: any = await updateApi(field.customFieldValueId, { value })
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess(`Champ "${field.name}" mis à jour`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new value via upsert — with metadata fallback when no ID
|
||||||
|
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
|
||||||
|
const result: any = await upsertCustomFieldValue(
|
||||||
|
field.customFieldId,
|
||||||
|
entityType,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Mutate in place (safe — _allFields is a ref, not computed)
|
||||||
|
if (result.data?.id) {
|
||||||
|
field.customFieldValueId = result.data.id
|
||||||
|
}
|
||||||
|
if (result.data?.customField?.id) {
|
||||||
|
field.customFieldId = result.data.customField.id
|
||||||
|
}
|
||||||
|
// Notify parent to update its reactive source
|
||||||
|
if (options.onValueCreated && result.data) {
|
||||||
|
options.onValueCreated(result.data)
|
||||||
|
}
|
||||||
|
showSuccess(`Champ "${field.name}" enregistré`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(`Erreur lors de l'enregistrement du champ "${field.name}"`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save all fields that have values
|
||||||
|
const saveAll = async (): Promise<string[]> => {
|
||||||
|
const id = toValue(options.entityId)
|
||||||
|
if (!id) return ['(entity ID missing)']
|
||||||
|
|
||||||
|
const failed: string[] = []
|
||||||
|
|
||||||
|
for (const field of fields.value) {
|
||||||
|
if (!shouldPersist(field)) continue
|
||||||
|
|
||||||
|
const value = formatValueForSave(field)
|
||||||
|
|
||||||
|
if (field.customFieldValueId) {
|
||||||
|
const result: any = await updateApi(field.customFieldValueId, { value })
|
||||||
|
if (!result.success) failed.push(field.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert with metadata fallback when no customFieldId
|
||||||
|
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
|
||||||
|
const result: any = await upsertCustomFieldValue(
|
||||||
|
field.customFieldId,
|
||||||
|
entityType,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (result.data?.id) {
|
||||||
|
field.customFieldValueId = result.data.id
|
||||||
|
}
|
||||||
|
if (result.data?.customField?.id) {
|
||||||
|
field.customFieldId = result.data.customField.id
|
||||||
|
}
|
||||||
|
if (options.onValueCreated && result.data) {
|
||||||
|
options.onValueCreated(result.data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failed.push(field.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** All merged fields filtered by context */
|
||||||
|
fields,
|
||||||
|
/** All merged fields (unfiltered) */
|
||||||
|
allFields: _allFields,
|
||||||
|
/** Whether all required fields have values */
|
||||||
|
requiredFilled,
|
||||||
|
/** Update a single field value via API */
|
||||||
|
update,
|
||||||
|
/** Save all fields with values, returns list of failed field names */
|
||||||
|
saveAll,
|
||||||
|
/** Re-merge from source definitions + values (call after fetching fresh data) */
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run lint + typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/composables/useCustomFieldInputs.ts
|
||||||
|
git commit -m "feat(custom-fields) : add unified useCustomFieldInputs composable"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Migrate shared components + standalone composables (atomic batch)
|
||||||
|
|
||||||
|
**Why batched:** `CustomFieldInputGrid.vue` and `CustomFieldDisplay.vue` receive fields from the composables. Migrating them separately would cause TypeScript errors in the intermediate state. Migrate all together.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/components/common/CustomFieldInputGrid.vue`
|
||||||
|
- Modify: `frontend/app/components/common/CustomFieldDisplay.vue`
|
||||||
|
- Modify: `frontend/app/composables/useComponentEdit.ts`
|
||||||
|
- Modify: `frontend/app/composables/useComponentCreate.ts`
|
||||||
|
- Modify: `frontend/app/composables/usePieceEdit.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Migrate `CustomFieldInputGrid.vue`**
|
||||||
|
|
||||||
|
Replace the import:
|
||||||
|
```typescript
|
||||||
|
// OLD
|
||||||
|
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
|
||||||
|
// NEW
|
||||||
|
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Migrate `CustomFieldDisplay.vue`**
|
||||||
|
|
||||||
|
Replace all imports from `entityCustomFieldLogic`. With the new typed `CustomFieldInput`, direct property access (`.name`, `.type`, `.options`, `.value`, `.readOnly`) replaces the `resolveFieldXxx()` wrapper functions. Read the full file and adapt the template accordingly.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Migrate `useComponentEdit.ts`**
|
||||||
|
|
||||||
|
Read the file. Key changes:
|
||||||
|
1. Replace `customFieldFormUtils` imports with the new module
|
||||||
|
2. Replace the imperative `refreshCustomFieldInputs` + `buildCustomFieldInputs` pattern with `useCustomFieldInputs`
|
||||||
|
3. Pass `selectedTypeStructure.value?.customFields` as definitions and `component.value?.customFieldValues` as values
|
||||||
|
4. Use the `onValueCreated` callback to push new values into `component.value.customFieldValues` so the reactive source stays in sync
|
||||||
|
5. Replace calls to `refreshCustomFieldInputs()` in watchers/fetch with calls to `refresh()` from the composable
|
||||||
|
|
||||||
|
- [ ] **Step 4: Migrate `useComponentCreate.ts`**
|
||||||
|
|
||||||
|
Same pattern. Definitions come from `selectedType.value?.structure?.customFields`, values are empty `[]` for creation.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Migrate `usePieceEdit.ts`**
|
||||||
|
|
||||||
|
Same pattern. Definitions from piece type structure, values from `piece.customFieldValues`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run lint + typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify**
|
||||||
|
|
||||||
|
Open each page in the browser:
|
||||||
|
- `/component/{id}` — check custom fields display and edit
|
||||||
|
- `/component/create` — check custom fields with default values
|
||||||
|
- `/pieces/{id}/edit` — check custom fields display and edit
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/components/common/CustomFieldInputGrid.vue frontend/app/components/common/CustomFieldDisplay.vue frontend/app/composables/useComponentEdit.ts frontend/app/composables/useComponentCreate.ts frontend/app/composables/usePieceEdit.ts
|
||||||
|
git commit -m "refactor(custom-fields) : migrate shared components and standalone composables to unified module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Migrate standalone pages (product + piece create)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/pages/pieces/create.vue`
|
||||||
|
- Modify: `frontend/app/pages/product/create.vue`
|
||||||
|
- Modify: `frontend/app/pages/product/[id]/edit.vue`
|
||||||
|
- Modify: `frontend/app/pages/product/[id]/index.vue`
|
||||||
|
|
||||||
|
These pages import directly from `customFieldFormUtils`. Replace with the new module.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read each file and identify all `customFieldFormUtils` imports**
|
||||||
|
|
||||||
|
- [ ] **Step 2: For each page, replace imports**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
```typescript
|
||||||
|
import { CustomFieldInput, normalizeCustomFieldInputs, buildCustomFieldInputs, requiredCustomFieldsFilled, saveCustomFieldValues } from '~/shared/utils/customFieldFormUtils'
|
||||||
|
```
|
||||||
|
With:
|
||||||
|
```typescript
|
||||||
|
import { type CustomFieldInput, normalizeDefinitions, mergeDefinitionsWithValues, requiredFieldsFilled } from '~/shared/utils/customFields'
|
||||||
|
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapt the logic in each page to use `useCustomFieldInputs` or the pure helpers as appropriate.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run lint + typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify**
|
||||||
|
|
||||||
|
Open each page in the browser:
|
||||||
|
- `/pieces/create` — check custom fields appear when selecting a type
|
||||||
|
- `/product/create` — same
|
||||||
|
- `/product/{id}/edit` — check fields display with values
|
||||||
|
- `/product/{id}` — check read-only display
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/pages/pieces/create.vue frontend/app/pages/product/create.vue frontend/app/pages/product/\[id\]/edit.vue frontend/app/pages/product/\[id\]/index.vue
|
||||||
|
git commit -m "refactor(custom-fields) : migrate product and piece pages to unified module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Clean category editor files (`componentStructure*.ts`)
|
||||||
|
|
||||||
|
**WHY BEFORE MACHINE PAGE:** `normalizeStructureForEditor` is used by `useMachineDetailCustomFields.ts`. If we change it after migrating the machine page, the machine page would break. So clean this first.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/shared/model/componentStructure.ts`
|
||||||
|
- Modify: `frontend/app/shared/model/componentStructureSanitize.ts`
|
||||||
|
- Modify: `frontend/app/shared/model/componentStructureHydrate.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the three files and identify custom field code**
|
||||||
|
|
||||||
|
The custom field code in these files handles the category editor (defining fields on a ModelType skeleton). It's the `sanitizeCustomFields` and `hydrateCustomFields` functions, plus custom field handling in `normalizeStructureForEditor` and `normalizeStructureForSave`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace custom field sanitize/hydrate with the unified module**
|
||||||
|
|
||||||
|
In `componentStructure.ts`, replace the custom field handling in `normalizeStructureForEditor`:
|
||||||
|
```typescript
|
||||||
|
// OLD
|
||||||
|
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
|
||||||
|
const customFields = sanitizedCustomFields.map((field) => { ... })
|
||||||
|
// NEW
|
||||||
|
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
|
||||||
|
const customFields = mergeDefinitionsWithValues(source.customFields, [])
|
||||||
|
```
|
||||||
|
|
||||||
|
**`optionsText` is now included** in `CustomFieldInput` (added in the type definition). `mergeDefinitionsWithValues` already computes `optionsText` from `options.join('\n')`, so all category editor textareas (`v-model="field.optionsText"`) will work without changes.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run lint + typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify TWO things**
|
||||||
|
|
||||||
|
1. Open `/component-category/{id}/edit` — check that custom fields are displayed, can be added/removed/reordered, and save correctly.
|
||||||
|
2. Open `/machine/{id}` — check that the machine page still works (it uses `normalizeStructureForEditor` via `useMachineDetailCustomFields.ts`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/shared/model/componentStructure.ts frontend/app/shared/model/componentStructureSanitize.ts frontend/app/shared/model/componentStructureHydrate.ts
|
||||||
|
git commit -m "refactor(custom-fields) : clean category editor structure files"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields`
|
||||||
|
|
||||||
|
**Depends on:** Task 6 (category editor cleaned — `normalizeStructureForEditor` already uses new types)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue`
|
||||||
|
- Modify: `frontend/app/components/machine/MachineInfoCard.vue`
|
||||||
|
- Modify: `frontend/app/composables/useMachineDetailCustomFields.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Migrate `MachineCustomFieldsCard.vue` and `MachineInfoCard.vue`**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
```typescript
|
||||||
|
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||||
|
```
|
||||||
|
With:
|
||||||
|
```typescript
|
||||||
|
import { formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/customFields'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace all calls to `formatCustomFieldValue(field)` with `formatValueForDisplay(field)`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Migrate `useMachineDetailCustomFields.ts` — pure custom field functions**
|
||||||
|
|
||||||
|
Replace the following pure-CF functions (~168 lines) with the new module:
|
||||||
|
|
||||||
|
| Old function (lines) | Replacement |
|
||||||
|
|---|---|
|
||||||
|
| `syncMachineCustomFields` (269-289) | `mergeDefinitionsWithValues(machine.customFields, machine.customFieldValues)` |
|
||||||
|
| `setMachineCustomFieldValue` (291-299) | Direct property mutation on the mutable `CustomFieldInput` |
|
||||||
|
| `updateMachineCustomField` (302-363) | `useCustomFieldInputs.update()` |
|
||||||
|
| `saveAllMachineCustomFields` (461-511) | `useCustomFieldInputs.saveAll()` |
|
||||||
|
| `saveAllContextCustomFields` (430-459) | Loop over link-level `useCustomFieldInputs` instances |
|
||||||
|
|
||||||
|
- [ ] **Step 3: Migrate `useMachineDetailCustomFields.ts` — mixed transform functions**
|
||||||
|
|
||||||
|
`transformCustomFields` (lines 71-158) and `transformComponentCustomFields` (lines 161-263) mix custom field merging with constructeur/product/document logic in a single `map()`. Refactor surgically:
|
||||||
|
|
||||||
|
**Inside `transformCustomFields` map callback**, replace lines 82-106 (valueEntries + merge + dedupe + filter):
|
||||||
|
```typescript
|
||||||
|
// OLD: ~25 lines of valueEntries building, normalizeCustomFieldValueEntry, mergeCustomFieldValuesWithDefinitions, dedupeCustomFieldEntries
|
||||||
|
// NEW: 2 lines
|
||||||
|
const customFields = filterByContext(
|
||||||
|
mergeDefinitionsWithValues(type.customFields ?? typePiece.customFields ?? [], piece.customFieldValues ?? []),
|
||||||
|
'standalone',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the rest of the map callback (constructeurs lines 108-133, product lines 119-120, assembly lines 135-158) unchanged.
|
||||||
|
|
||||||
|
**Inside `transformComponentCustomFields` map callback**, replace lines 175-199 (same pattern):
|
||||||
|
```typescript
|
||||||
|
const customFields = filterByContext(
|
||||||
|
mergeDefinitionsWithValues(type.customFields ?? [], component.customFieldValues ?? actualComponent?.customFieldValues ?? []),
|
||||||
|
'standalone',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep recursive calls (lines 201-209) and constructeur/product/document logic (lines 212-263) unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run lint + typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify**
|
||||||
|
|
||||||
|
Open a machine page (`/machine/{id}`) that has:
|
||||||
|
- Machine-level custom fields
|
||||||
|
- Components with regular custom fields
|
||||||
|
- Components with machineContextOnly fields
|
||||||
|
Check display, edit, and save for all three.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/components/machine/MachineCustomFieldsCard.vue frontend/app/components/machine/MachineInfoCard.vue frontend/app/composables/useMachineDetailCustomFields.ts
|
||||||
|
git commit -m "refactor(custom-fields) : migrate machine page to unified module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Migrate hierarchy components (`ComponentItem.vue`, `PieceItem.vue`)
|
||||||
|
|
||||||
|
**Depends on:** Task 7 (machine page — parent pre-merges custom fields into `CustomFieldInput[]`)
|
||||||
|
|
||||||
|
**Data contract:** The parent (`useMachineDetailCustomFields.transformComponentCustomFields`) already pre-merges custom fields into `component.customFields` (a `CustomFieldInput[]`). The Item components should NOT re-merge — they display the pre-merged data directly and use the API composable only for saves/updates.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/components/ComponentItem.vue`
|
||||||
|
- Modify: `frontend/app/components/PieceItem.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Migrate `ComponentItem.vue`**
|
||||||
|
|
||||||
|
Read the file. Replace:
|
||||||
|
```typescript
|
||||||
|
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||||
|
import { resolveFieldKey, resolveFieldName, ... } from '~/shared/utils/entityCustomFieldLogic'
|
||||||
|
```
|
||||||
|
With:
|
||||||
|
```typescript
|
||||||
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
|
import { fieldKey, formatValueForDisplay, hasDisplayableValue, mergeDefinitionsWithValues, type CustomFieldInput } from '~/shared/utils/customFields'
|
||||||
|
```
|
||||||
|
|
||||||
|
Key changes:
|
||||||
|
1. **Remove `useEntityCustomFields`** — the parent already pre-merges. Use `props.component.customFields` directly (already `CustomFieldInput[]` after Task 7)
|
||||||
|
2. **For display:** use `hasDisplayableValue(field)` and `formatValueForDisplay(field)` instead of `resolveFieldXxx()` wrappers
|
||||||
|
3. **For edits/saves:** use `useCustomFields()` directly (the HTTP layer) instead of `useEntityCustomFields().updateCustomField`
|
||||||
|
4. **For context fields** (`component.contextCustomFields` + `component.contextCustomFieldValues`): merge locally with `mergeDefinitionsWithValues` — these are NOT pre-merged by the parent since they come as separate arrays
|
||||||
|
5. **Replace `resolveCustomFieldId(field)`** with `field.customFieldId` (direct property access on `CustomFieldInput`)
|
||||||
|
6. **Replace `resolveFieldId(field)`** with `field.customFieldValueId`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Migrate `PieceItem.vue`**
|
||||||
|
|
||||||
|
Same pattern as ComponentItem but with `entityType: 'piece'` and `props.piece.customFields`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run lint + typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify**
|
||||||
|
|
||||||
|
Open a machine page and expand components/pieces in the hierarchy. Check custom fields display correctly.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/components/ComponentItem.vue frontend/app/components/PieceItem.vue
|
||||||
|
git commit -m "refactor(custom-fields) : migrate ComponentItem and PieceItem to unified module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Delete old files + final cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Delete: `frontend/app/shared/utils/entityCustomFieldLogic.ts`
|
||||||
|
- Delete: `frontend/app/shared/utils/customFieldUtils.ts`
|
||||||
|
- Delete: `frontend/app/shared/utils/customFieldFormUtils.ts`
|
||||||
|
- Delete: `frontend/app/composables/useEntityCustomFields.ts`
|
||||||
|
- Delete or rewrite: `frontend/tests/shared/customFieldFormUtils.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify no remaining imports of old files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && grep -r "entityCustomFieldLogic\|customFieldUtils\|customFieldFormUtils\|useEntityCustomFields" app/ --include="*.ts" --include="*.vue" -l
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no results (0 files).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Delete old files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm frontend/app/shared/utils/entityCustomFieldLogic.ts
|
||||||
|
rm frontend/app/shared/utils/customFieldUtils.ts
|
||||||
|
rm frontend/app/shared/utils/customFieldFormUtils.ts
|
||||||
|
rm frontend/app/composables/useEntityCustomFields.ts
|
||||||
|
rm frontend/tests/shared/customFieldFormUtils.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full lint + typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final smoke test**
|
||||||
|
|
||||||
|
Test all 4 contexts in the browser:
|
||||||
|
1. **Machine fields** — `/machine/{id}` → machine-level custom fields
|
||||||
|
2. **Standalone entity** — `/component/{id}` → custom fields display and edit
|
||||||
|
3. **Machine context** — `/machine/{id}` → expand a component → machineContextOnly fields
|
||||||
|
4. **Category editor** — `/component-category/{id}/edit` → custom field definitions
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "refactor(custom-fields) : delete old parallel custom field modules"
|
||||||
|
```
|
||||||
148
docs/superpowers/session-2026-04-04-ux-overhaul.md
Normal file
148
docs/superpowers/session-2026-04-04-ux-overhaul.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Session 04-05 avril 2026 — Refonte UX/UI complète Inventory
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
L'utilisateur (gestionnaire) remonte que les utilisateurs novices se perdent dans l'app Inventory (gestion d'inventaire industriel : machines, composants, pièces, produits). Ils découvrent le domaine ET l'app en même temps, remplissent les machines depuis de la documentation papier/PDF.
|
||||||
|
|
||||||
|
## Ce qui a été fait
|
||||||
|
|
||||||
|
### 1. Analyse UX/UI complète
|
||||||
|
- Exploration en profondeur des 65+ composants, toutes les pages, composables et patterns
|
||||||
|
- Diagnostic : navigation top-down uniquement, pas de liens inverses, pas de breadcrumbs, navbar mélange tout, pages trop longues, mode lecture ressemble à un formulaire disabled
|
||||||
|
- Identification de 23 améliorations organisées en 4 phases
|
||||||
|
|
||||||
|
### 2. Spec rédigée
|
||||||
|
**Fichier :** `docs/superpowers/specs/2026-04-04-ux-overhaul-design.md`
|
||||||
|
|
||||||
|
23 sections couvrant :
|
||||||
|
- Réorganisation navbar par domaine métier
|
||||||
|
- Breadcrumbs contextuels
|
||||||
|
- Liaisons inverses "Utilisé dans"
|
||||||
|
- Liens cliquables dans la hiérarchie machine
|
||||||
|
- Système d'onglets partagé (machine + composant + pièce + produit)
|
||||||
|
- Pages catalogue unifiées (catalogue + catégories en onglets)
|
||||||
|
- Recherche globale (**retirée** à la demande de l'utilisateur)
|
||||||
|
- Raccourcis clavier (**retirés** à la demande)
|
||||||
|
- Mode lecture texte brut, empty states, toasts, responsive, etc.
|
||||||
|
|
||||||
|
### 3. Phase 1 — Quick wins (9 améliorations, 0 backend)
|
||||||
|
|
||||||
|
**Plan :** `docs/superpowers/plans/2026-04-04-ux-quick-wins.md`
|
||||||
|
|
||||||
|
| Changement | Fichiers modifiés |
|
||||||
|
|-----------|-------------------|
|
||||||
|
| Liens cliquables dans hiérarchie machine | ComponentItem, PieceItem, MachineProductsCard |
|
||||||
|
| Site → machines (badge cliquable) | SiteCard, index.vue |
|
||||||
|
| Retour contextuel (NuxtLink au lieu de router.back) | DetailHeader |
|
||||||
|
| Confirmations sur toutes les suppressions | CommentSection, machine/[id].vue |
|
||||||
|
| Header sticky composants expanded | ComponentItem |
|
||||||
|
| DataTable fixedLayout opt-in + minWidth | DataTable.vue, dataTable.ts |
|
||||||
|
| Mode lecture texte brut (26 div-inputs → `<p>`) | MachineInfoCard, 3 pages détail |
|
||||||
|
| Compteurs titres sections machine | MachineComponentsCard, MachinePiecesCard, MachineDocumentsCard |
|
||||||
|
| Cohérence fiches (liens catégorie + EntityVersionList) | 3 pages détail entité |
|
||||||
|
|
||||||
|
**Review Phase 1 :** a détecté 4 issues corrigées :
|
||||||
|
- `component.entityId` → `component.composantId` (property n'existait pas)
|
||||||
|
- `piece.entityId` → `piece.pieceId`
|
||||||
|
- `table-fixed` global → opt-in via prop `fixedLayout`
|
||||||
|
- NuxtLinks sans `?from=machine&machineId=xxx` → ajouté
|
||||||
|
|
||||||
|
### 4. Phase 2 — Refactoring structurel (7 améliorations)
|
||||||
|
|
||||||
|
**Plan :** `docs/superpowers/plans/2026-04-04-ux-phase2-structural.md`
|
||||||
|
|
||||||
|
| Changement | Fichiers créés/modifiés |
|
||||||
|
|-----------|------------------------|
|
||||||
|
| EntityTabs composant partagé | `components/common/EntityTabs.vue` (nouveau) |
|
||||||
|
| Onglets page machine + header compact | machine/[id].vue, MachineDetailHeader.vue |
|
||||||
|
| Onglets composant/pièce/produit | 3 pages détail |
|
||||||
|
| Pages catalogue unifiées /catalogues/* | 3 nouvelles pages + ManagementView modifié |
|
||||||
|
| Navbar réorganisée (Catalogues + Administration) | AppNavbar.vue |
|
||||||
|
| Breadcrumbs contextuels | `components/layout/AppBreadcrumb.vue` (nouveau), app.vue |
|
||||||
|
| Redirections legacy URLs | `middleware/legacy-redirects.global.ts` (nouveau) |
|
||||||
|
| Guard modifications non sauvegardées | `composables/useUnsavedGuard.ts` (nouveau) |
|
||||||
|
|
||||||
|
### 5. Phase 3 — Harmonisation visuelle (3 améliorations)
|
||||||
|
|
||||||
|
| Changement | Fichiers |
|
||||||
|
|-----------|----------|
|
||||||
|
| EmptyState composant partagé | `components/common/EmptyState.vue` (nouveau), 3 pages |
|
||||||
|
| Toasts erreur persistent + barre progression | useToast.ts, ToastContainer.vue |
|
||||||
|
| Responsive mobile (breadcrumbs tronqués, tabs scroll) | AppBreadcrumb, EntityTabs, vérification grids |
|
||||||
|
|
||||||
|
### 6. Phase 4 — Backend + reverse links (6 améliorations)
|
||||||
|
|
||||||
|
| Changement | Fichiers |
|
||||||
|
|-----------|----------|
|
||||||
|
| Endpoint `/api/{entity}/{id}/used-in` | `src/Controller/UsedInController.php` (nouveau) |
|
||||||
|
| UsedInSection frontend | `composables/useUsedIn.ts` + `components/common/UsedInSection.vue` (nouveaux), 3 pages détail |
|
||||||
|
| Endpoint `/api/constructeurs/stats` | `src/Controller/ConstructeurStatsController.php` (nouveau) |
|
||||||
|
| Page fournisseurs enrichie (compteurs cliquables) | constructeurs.vue |
|
||||||
|
| Endpoint `/api/model_types/{id}/related-items` | `src/Controller/ModelTypeRelatedItemsController.php` (nouveau) |
|
||||||
|
| Modal catégorie enrichie (machine count + liens) | RelatedItemsModal.vue |
|
||||||
|
|
||||||
|
## Bugs découverts et corrigés en cours de route
|
||||||
|
|
||||||
|
| Bug | Cause | Fix |
|
||||||
|
|-----|-------|-----|
|
||||||
|
| `<script setup>` sans `lang="ts"` | Agents ont ajouté `as string` dans des fichiers JS | Ajouté `lang="ts"` sur ComponentItem, PieceItem, machine/[id] |
|
||||||
|
| `Cannot access 'selectedType' before initialization` | Bug pré-existant dans usePieceEdit.ts — `resolvedStructure` utilisait `selectedType` avant sa déclaration | Déplacé `resolvedStructure` avant `useCustomFieldInputs` |
|
||||||
|
| `CommonEmptyState` non résolu | `pathPrefix: false` dans nuxt.config → les composants dans `common/` s'importent sans préfixe | Renommé `CommonEmptyState` → `EmptyState`, `CommonUsedInSection` → `UsedInSection` |
|
||||||
|
| `/api/constructeurs/stats` retourne 404 | Route API Platform `/api/constructeurs/{id}` matchait "stats" comme un {id} | Ajouté `priority: 1` sur la route bulk stats |
|
||||||
|
| Compteurs fournisseurs tous à 0 | Tables `*_constructeur_links` vides — liens jamais migrés depuis les tables legacy M2M | Restauré depuis backup + créé migration Doctrine |
|
||||||
|
| Pages `/catalogues/*` manquantes sur le disque | Fichiers committés par agents mais perdus dans le working tree (confusion `frontend/` vs `app/`) | Restauré depuis git history |
|
||||||
|
|
||||||
|
## Problème de données découvert
|
||||||
|
|
||||||
|
Les **liens constructeur ↔ entités** n'avaient jamais été migrés des anciennes tables ManyToMany (`_composantconstructeurs`, `_piececonstructeurs`) vers les nouvelles tables de liens (`*_constructeur_links`). Ce problème est **pré-existant** au refactoring UX.
|
||||||
|
|
||||||
|
### Données restaurées en local
|
||||||
|
- 3 liens composant-constructeur
|
||||||
|
- 23 liens pièce-constructeur (dont 6 Limatech remappé avec le nouvel ID)
|
||||||
|
|
||||||
|
### Données irrémédiablement perdues (entités supprimées)
|
||||||
|
- **Convoyeur à Bande** → était lié à Brillaud + Bühler
|
||||||
|
- **Sangle E12** → était liée à NETCO
|
||||||
|
- **Arbre du tambour tête E6** → était lié à Dexis
|
||||||
|
|
||||||
|
### Migrations créées pour la prod
|
||||||
|
1. `migrations/Version20260405_MigrateConstructeurLinks.php` — copie depuis les tables legacy M2M (si elles existent)
|
||||||
|
2. `migrations/Version20260405_RestoreConstructeurLinksFromBackup.php` — fallback : insère directement les données du backup (3), nettoie les orphelins
|
||||||
|
|
||||||
|
**Pour restaurer en prod :** `php bin/console doctrine:migrations:migrate`
|
||||||
|
|
||||||
|
## Fichiers de référence
|
||||||
|
|
||||||
|
| Fichier | Contenu |
|
||||||
|
|---------|---------|
|
||||||
|
| `docs/superpowers/specs/2026-04-04-ux-overhaul-design.md` | Spec complète des 23 améliorations |
|
||||||
|
| `docs/superpowers/plans/2026-04-04-ux-quick-wins.md` | Plan Phase 1 (11 tasks) |
|
||||||
|
| `docs/superpowers/plans/2026-04-04-ux-phase2-structural.md` | Plan Phase 2 (11 tasks) |
|
||||||
|
| `docs/superpowers/session-2026-04-04-ux-overhaul.md` | Ce résumé |
|
||||||
|
|
||||||
|
## Branche
|
||||||
|
`feat/ux-quick-wins` — ~30 commits depuis `develop`
|
||||||
|
|
||||||
|
## Nouveaux composants/composables créés
|
||||||
|
- `app/components/common/EntityTabs.vue`
|
||||||
|
- `app/components/common/EmptyState.vue`
|
||||||
|
- `app/components/common/UsedInSection.vue`
|
||||||
|
- `app/components/layout/AppBreadcrumb.vue`
|
||||||
|
- `app/composables/useUsedIn.ts`
|
||||||
|
- `app/composables/useUnsavedGuard.ts`
|
||||||
|
- `app/middleware/legacy-redirects.global.ts`
|
||||||
|
- `app/pages/catalogues/composants.vue`
|
||||||
|
- `app/pages/catalogues/pieces.vue`
|
||||||
|
- `app/pages/catalogues/produits.vue`
|
||||||
|
|
||||||
|
## Nouveaux controllers backend
|
||||||
|
- `src/Controller/UsedInController.php`
|
||||||
|
- `src/Controller/ConstructeurStatsController.php`
|
||||||
|
- `src/Controller/ModelTypeRelatedItemsController.php`
|
||||||
|
|
||||||
|
## Points d'attention pour la suite
|
||||||
|
1. **Tester visuellement** toutes les pages sur `localhost:3001` avant merge
|
||||||
|
2. **Lancer les migrations en prod** pour restaurer les liens constructeur
|
||||||
|
3. Les anciennes URLs (`/component-catalog`, `/pieces-catalog`, etc.) redirigent automatiquement
|
||||||
|
4. Le menu Administration n'est visible que pour les gestionnaires/admins (`canEdit`)
|
||||||
|
5. L'onglet Catégories dans les pages catalogue n'est visible que pour `canEdit`
|
||||||
|
6. Le `useUnsavedGuard` n'est pas encore intégré dans les pages (composable créé, pas branché)
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# Custom Fields Simplification — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-04
|
||||||
|
**Scope:** Backend minor cleanup + Frontend unification of the custom fields system
|
||||||
|
**Constraint:** Everything must work after — progressive migration with verification at each step
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The custom fields system has grown into 3 parallel frontend implementations (~2900 lines across 9 files) due to accumulated defensive code. This caused data bugs (orphaned fields, lost linkage) and makes every change risky.
|
||||||
|
|
||||||
|
## 4 Custom Field Contexts
|
||||||
|
|
||||||
|
1. **Machine** — fields defined directly on the machine (`CustomField.machineid` FK), values on machine
|
||||||
|
2. **Standalone entity** — fields defined in ModelType (category), values on composant/piece/product. Visible when opening the entity directly
|
||||||
|
3. **Machine context** — fields with `machineContextOnly=true` defined in ModelType, values stored on `MachineComponentLink`/`MachinePieceLink`. Visible only from the machine detail page
|
||||||
|
4. **Category editor** — UI for defining/editing custom fields in a ModelType skeleton
|
||||||
|
|
||||||
|
## Backend Changes
|
||||||
|
|
||||||
|
### Minor — format already consistent
|
||||||
|
|
||||||
|
After review, `MachineStructureController` already serializes custom fields in the same format as API Platform:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// CustomFieldValue (from normalizeCustomFieldValues)
|
||||||
|
{
|
||||||
|
"id": "cfv-123",
|
||||||
|
"value": "USOCOME",
|
||||||
|
"customField": {
|
||||||
|
"id": "cf-456",
|
||||||
|
"name": "MARQUE",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"options": [],
|
||||||
|
"defaultValue": null,
|
||||||
|
"orderIndex": 0,
|
||||||
|
"machineContextOnly": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// CustomField definition (from normalizeCustomFieldDefinitions)
|
||||||
|
{
|
||||||
|
"id": "cf-456",
|
||||||
|
"name": "MARQUE",
|
||||||
|
"type": "text",
|
||||||
|
"required": false,
|
||||||
|
"options": [],
|
||||||
|
"defaultValue": null,
|
||||||
|
"orderIndex": 0,
|
||||||
|
"machineContextOnly": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The only backend task is adding `defaultValue` to the API Platform serialization groups on `CustomField.php` so that both API Platform and the custom controller return it.
|
||||||
|
|
||||||
|
**Context fields on links** are returned as two separate arrays:
|
||||||
|
- `contextCustomFields` — definitions filtered to `machineContextOnly=true`
|
||||||
|
- `contextCustomFieldValues` — values stored on `MachineComponentLink`/`MachinePieceLink`
|
||||||
|
|
||||||
|
This format stays as-is. The frontend unified module handles the merge.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `src/Entity/CustomField.php` — add `#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]` to `defaultValue`
|
||||||
|
|
||||||
|
### Legacy `{key, value}` format in DB
|
||||||
|
|
||||||
|
`SkeletonStructureService::normalizeCustomFieldData()` accepts two formats:
|
||||||
|
- Legacy: `{key: "name", value: {type, required, options?, defaultValue?}}`
|
||||||
|
- Standard: `{name, type, required, options?, defaultValue?}`
|
||||||
|
|
||||||
|
**Pre-migration check required:** verify if any `ModelType` rows still have the legacy format in their structure data. If yes, write a one-time DB migration to normalize them before removing the frontend parsing code in Step 5. If no legacy data exists, the parsing code can be safely removed.
|
||||||
|
|
||||||
|
## Frontend Changes
|
||||||
|
|
||||||
|
### New Unified Module (2 files, ~400 lines total)
|
||||||
|
|
||||||
|
**`shared/utils/customFields.ts`** (~180 lines) — Pure logic, zero Vue dependency
|
||||||
|
|
||||||
|
Types:
|
||||||
|
- `CustomFieldDefinition` — `{ id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }`
|
||||||
|
- `CustomFieldValue` — `{ id, value, customField: CustomFieldDefinition }`
|
||||||
|
- `CustomFieldInput` — `{ ...CustomFieldDefinition, value, customFieldId, customFieldValueId }` (the merged type used by forms)
|
||||||
|
|
||||||
|
Functions:
|
||||||
|
- `mergeDefinitionsWithValues(definitions, values)` → `CustomFieldInput[]` — the ONE merge function replacing the 3 current ones. Matches by `customField.id` then by `name`. When no value exists for a definition, uses `defaultValue` as initial value.
|
||||||
|
- `filterByContext(fields, context: 'standalone' | 'machine')` — filters on `machineContextOnly`
|
||||||
|
- `sortByOrder(fields)` — sorts by `orderIndex`
|
||||||
|
- `formatValueForSave(field)` / `shouldPersist(field)` — persistence helpers
|
||||||
|
- `formatValueForDisplay(field)` — display helper (e.g. boolean → `Oui/Non`), replaces `formatCustomFieldValue` from `customFieldUtils.ts`
|
||||||
|
- `fieldKey(field, index)` — stable key for v-for, replaces `fieldKey` from `customFieldFormUtils.ts`
|
||||||
|
|
||||||
|
**`composables/useCustomFieldInputs.ts`** (~220 lines) — Reactive, wraps pure helpers
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function useCustomFieldInputs(options: {
|
||||||
|
definitions: MaybeRef<CustomFieldDefinition[]>
|
||||||
|
values: MaybeRef<CustomFieldValue[]>
|
||||||
|
entityType: 'machine' | 'composant' | 'piece' | 'product' | 'machineComponentLink' | 'machinePieceLink'
|
||||||
|
entityId: MaybeRef<string | null>
|
||||||
|
context?: 'standalone' | 'machine' // defaults to 'standalone'
|
||||||
|
}): {
|
||||||
|
fields: ComputedRef<CustomFieldInput[]>
|
||||||
|
update: (field: CustomFieldInput) => Promise<void>
|
||||||
|
saveAll: () => Promise<string[]> // returns failed field names
|
||||||
|
requiredFilled: ComputedRef<boolean>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage for context 3 (machine context fields on links):**
|
||||||
|
```ts
|
||||||
|
// For each MachineComponentLink, instantiate with:
|
||||||
|
const contextFields = useCustomFieldInputs({
|
||||||
|
definitions: link.contextCustomFields, // from MachineStructureController
|
||||||
|
values: link.contextCustomFieldValues, // from MachineStructureController
|
||||||
|
entityType: 'machineComponentLink',
|
||||||
|
entityId: link.id,
|
||||||
|
context: 'machine',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files Deleted After Migration
|
||||||
|
|
||||||
|
| File | Lines | Replaced by |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `shared/utils/entityCustomFieldLogic.ts` | 335 | `shared/utils/customFields.ts` |
|
||||||
|
| `shared/utils/customFieldUtils.ts` | 440 | `shared/utils/customFields.ts` |
|
||||||
|
| `shared/utils/customFieldFormUtils.ts` | 404 | `shared/utils/customFields.ts` + `composables/useCustomFieldInputs.ts` |
|
||||||
|
| `composables/useEntityCustomFields.ts` | 181 | `composables/useCustomFieldInputs.ts` |
|
||||||
|
|
||||||
|
Additionally refactored (not deleted):
|
||||||
|
- `composables/useMachineDetailCustomFields.ts` — custom fields code extracted, uses new module (keeps non-CF logic: constructeurs, products, transforms)
|
||||||
|
- `shared/model/componentStructure.ts` — custom fields code removed (kept: structure/skeleton logic)
|
||||||
|
- `shared/model/componentStructureSanitize.ts` — custom fields sanitize code removed
|
||||||
|
- `shared/model/componentStructureHydrate.ts` — custom fields hydrate code removed
|
||||||
|
|
||||||
|
### All consuming files to migrate
|
||||||
|
|
||||||
|
**Composables:**
|
||||||
|
- `composables/useComponentEdit.ts` — use `useCustomFieldInputs`
|
||||||
|
- `composables/useComponentCreate.ts` — use `useCustomFieldInputs`
|
||||||
|
- `composables/usePieceEdit.ts` — use `useCustomFieldInputs`
|
||||||
|
- `composables/useMachineDetailCustomFields.ts` — use `useCustomFieldInputs` for all 3 machine sub-cases
|
||||||
|
|
||||||
|
**Pages:**
|
||||||
|
- `pages/component/[id]/index.vue` — already uses composable, minimal changes
|
||||||
|
- `pages/component/[id]/edit.vue` — already uses composable, minimal changes
|
||||||
|
- `pages/component/create.vue` — already uses composable, minimal changes
|
||||||
|
- `pages/pieces/create.vue` — imports from `customFieldFormUtils`, migrate to new types
|
||||||
|
- `pages/pieces/[id]/edit.vue` — already uses composable, minimal changes
|
||||||
|
- `pages/product/create.vue` — imports from `customFieldFormUtils`, migrate to new types
|
||||||
|
- `pages/product/[id]/edit.vue` — imports from `customFieldFormUtils`, migrate to new types
|
||||||
|
- `pages/product/[id]/index.vue` — imports from `customFieldFormUtils`, migrate to new types
|
||||||
|
|
||||||
|
**Shared components:**
|
||||||
|
- `components/common/CustomFieldDisplay.vue` — imports 7 functions from `entityCustomFieldLogic`, rewrite with unified `CustomFieldInput` type
|
||||||
|
- `components/common/CustomFieldInputGrid.vue` — imports `fieldKey` + `CustomFieldInput` from `customFieldFormUtils`, update imports
|
||||||
|
- `components/ComponentItem.vue` — imports from `entityCustomFieldLogic` + `useEntityCustomFields`, migrate
|
||||||
|
- `components/PieceItem.vue` — imports from `entityCustomFieldLogic` + `useEntityCustomFields`, migrate
|
||||||
|
- `components/machine/MachineCustomFieldsCard.vue` — imports `formatCustomFieldValue` from `customFieldUtils`, use `formatValueForDisplay`
|
||||||
|
- `components/machine/MachineInfoCard.vue` — imports `formatCustomFieldValue` from `customFieldUtils`, use `formatValueForDisplay`
|
||||||
|
- `components/model-types/ModelTypeForm.vue` — use `shared/utils/customFields.ts` types
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
- `tests/shared/customFieldFormUtils.test.ts` — rewrite for new module or delete
|
||||||
|
|
||||||
|
## Migration Strategy — Progressive (6 steps)
|
||||||
|
|
||||||
|
### Step 1: Backend minor fix + DB check
|
||||||
|
- Add `defaultValue` to serialization groups in `CustomField.php`
|
||||||
|
- Check DB for legacy `{key, value}` format in `model_types.structure` — write migration if needed
|
||||||
|
- **Verify:** call `/api/composants/{id}`, confirm `defaultValue` appears in `customField` objects
|
||||||
|
|
||||||
|
### Step 2: Create new module
|
||||||
|
- Write `shared/utils/customFields.ts` and `composables/useCustomFieldInputs.ts`
|
||||||
|
- Port existing test to new module
|
||||||
|
- **Verify:** import in a test page, confirm merge/filter/sort/defaultValue work with real data
|
||||||
|
|
||||||
|
### Step 3: Migrate standalone pages (composant/piece/product)
|
||||||
|
- Refactor composables: `useComponentEdit.ts`, `useComponentCreate.ts`, `usePieceEdit.ts`
|
||||||
|
- Refactor pages: `pieces/create.vue`, `product/create.vue`, `product/[id]/edit.vue`, `product/[id]/index.vue`
|
||||||
|
- Refactor shared components: `CustomFieldInputGrid.vue`, `CustomFieldDisplay.vue`
|
||||||
|
- **Verify per page:** open entity, check fields display with values (including defaultValue on new entities), modify a value, confirm save works
|
||||||
|
|
||||||
|
### Step 4: Migrate machine page + hierarchy components
|
||||||
|
- Refactor `useMachineDetailCustomFields.ts` — use `useCustomFieldInputs` for:
|
||||||
|
- Machine direct fields (definitions from `machine.customFields`, values from `machine.customFieldValues`)
|
||||||
|
- Standalone component/piece fields (definitions from `type.customFields`, values from entity's `customFieldValues`, filtered `machineContextOnly=false`)
|
||||||
|
- Machine context fields (definitions from `link.contextCustomFields`, values from `link.contextCustomFieldValues`)
|
||||||
|
- Refactor `ComponentItem.vue`, `PieceItem.vue` — use `useCustomFieldInputs` instead of `useEntityCustomFields`
|
||||||
|
- Refactor `MachineCustomFieldsCard.vue`, `MachineInfoCard.vue` — use `formatValueForDisplay`
|
||||||
|
- **Verify:** open a machine with components that have both normal AND machine-context custom fields, check both display and save correctly
|
||||||
|
|
||||||
|
### Step 5: Migrate category editor
|
||||||
|
- Check DB for legacy `{key, value}` format — run migration if needed
|
||||||
|
- Clean `componentStructure.ts`, `componentStructureSanitize.ts`, `componentStructureHydrate.ts` — remove custom fields code, use unified types from `customFields.ts`
|
||||||
|
- Refactor `ModelTypeForm.vue`
|
||||||
|
- **Verify:** edit a component category, modify skeleton custom fields, save, check linked components see changes
|
||||||
|
|
||||||
|
### Step 6: Cleanup
|
||||||
|
- Delete the 4 old files
|
||||||
|
- Delete or rewrite `tests/shared/customFieldFormUtils.test.ts`
|
||||||
|
- `npm run lint:fix` + `npx nuxi typecheck` = 0 errors
|
||||||
|
- Final smoke test of all 4 contexts
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
- **~2900 lines → ~400 lines** + simplified consumers
|
||||||
|
- **9 custom fields files → 2**
|
||||||
|
- **3 parallel systems → 1**
|
||||||
|
- **1 unified data format** understood by all pages
|
||||||
|
- **`defaultValue` properly handled** across all contexts
|
||||||
|
- **Legacy format eliminated** from DB and code
|
||||||
@@ -422,16 +422,16 @@ INSERT INTO public.constructeurs (id, name, email, phone, createdat, updatedat)
|
|||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Data for Name: _composantconstructeurs; Type: TABLE DATA; Schema: public; Owner: -
|
-- Data for Name: composant_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
||||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmgz7fd3l009y47fff1l4g0p0', 'cmgqp5dvp00014705qpkci8qc');
|
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000001', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgqp5dvp00014705qpkci8qc', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm');
|
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000002', 'cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm');
|
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000003', 'cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj');
|
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000004', 'cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmkqps2h8001q1eq6k2uxopfo', 'cmkqpnznr001p1eq6hdh2ept8');
|
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000005', 'cmkqps2h8001q1eq6k2uxopfo', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmkqyn2jm002m1eq6ws83lgwx', 'cmkqpnznr001p1eq6hdh2ept8');
|
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000006', 'cmkqyn2jm002m1eq6ws83lgwx', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cl9b1583768c7c9fe6cfe93a11', 'cmkqpnznr001p1eq6hdh2ept8');
|
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000007', 'cl9b1583768c7c9fe6cfe93a11', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
@@ -461,7 +461,7 @@ INSERT INTO public.machines (id, name, reference, prix, createdat, updatedat, si
|
|||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Data for Name: _machineconstructeurs; Type: TABLE DATA; Schema: public; Owner: -
|
-- Data for Name: machine_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
||||||
|
|
||||||
@@ -588,25 +588,25 @@ INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, type
|
|||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Data for Name: _piececonstructeurs; Type: TABLE DATA; Schema: public; Owner: -
|
-- Data for Name: piece_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
|
||||||
--
|
--
|
||||||
|
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmizudzfy00021e2w2mtd9zv8', 'cmizu5ugx00011e2wjpr6nb3k');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000001', 'cmizudzfy00021e2w2mtd9zv8', 'cmizu5ugx00011e2wjpr6nb3k', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000002', 'cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000003', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000004', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000005', 'cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000006', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000007', 'cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'cmkqpnznr001p1eq6hdh2ept8');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000008', 'cmkqzl1oa002v1eq6erkt5544', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000009', 'cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000010', 'cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000011', 'cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9sk000047uuwm6u20mj');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000012', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9sk000047uuwm6u20mj', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9te000547uuond39s1c');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000013', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9te000547uuond39s1c', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9tb000447uuuddgakar');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000014', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9tb000447uuuddgakar', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmhaac3vo003547v7s1wv6jhv');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000015', 'cl89d9641d47f52c5385f83d5c', 'cmhaac3vo003547v7s1wv6jhv', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9tm000647uu6em8thyq');
|
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000016', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9tm000647uu6em8thyq', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@open-settings="displaySettingsOpen = true"
|
@open-settings="displaySettingsOpen = true"
|
||||||
@logout="handleLogout"
|
@logout="handleLogout"
|
||||||
/>
|
/>
|
||||||
|
<AppBreadcrumb />
|
||||||
|
|
||||||
<main class="flex-1">
|
<main class="flex-1">
|
||||||
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />
|
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />
|
||||||
|
|||||||
@@ -255,7 +255,16 @@ const handleResolve = async (commentId: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
const handleDelete = async (commentId: string) => {
|
const handleDelete = async (commentId: string) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Supprimer ce commentaire ?',
|
||||||
|
message: 'Cette action est irréversible.',
|
||||||
|
confirmText: 'Supprimer',
|
||||||
|
dangerous: true,
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
const result = await deleteComment(commentId)
|
const result = await deleteComment(commentId)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
comments.value = comments.value.filter(c => c.id !== commentId)
|
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
<!-- Root Components -->
|
<!-- Root Components -->
|
||||||
<div v-for="component in components" :key="component.id" class="border border-gray-200 rounded-lg p-4">
|
<div v-for="component in components" :key="component.id">
|
||||||
<ComponentItem
|
<ComponentItem
|
||||||
:component="component"
|
:component="component"
|
||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
|
|||||||
@@ -13,17 +13,47 @@
|
|||||||
@updated="handleDocumentUpdated"
|
@updated="handleDocumentUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Component Header -->
|
<!-- ═══ HEADER BAR ═══ -->
|
||||||
<div class="flex items-center gap-3 p-3 rounded-lg cursor-pointer" :class="component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'" @click="toggleCollapse">
|
<div
|
||||||
<IconLucideChevronRight
|
class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
|
||||||
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
|
:class="[
|
||||||
:class="{ 'rotate-90': !isCollapsed }"
|
component.pendingEntity
|
||||||
aria-hidden="true"
|
? 'bg-error/10 border border-error/40 hover:border-error/60'
|
||||||
/>
|
: 'bg-base-200/70 border border-base-300/30 hover:border-base-300/60 hover:bg-base-200',
|
||||||
<div class="flex-1 min-w-0">
|
!isCollapsed ? 'sticky top-16 z-10 shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
|
||||||
|
]"
|
||||||
|
@click="toggleCollapse"
|
||||||
|
>
|
||||||
|
<!-- Chevron -->
|
||||||
|
<div
|
||||||
|
class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
|
||||||
|
:class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
|
||||||
|
>
|
||||||
|
<IconLucideChevronRight
|
||||||
|
class="w-3.5 h-3.5 transition-transform duration-200"
|
||||||
|
:class="[
|
||||||
|
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
|
||||||
|
]"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<!-- Row 1: Name + identifiers -->
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
|
<h3 class="text-sm font-bold tracking-tight truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||||
{{ component.name }}
|
<NuxtLink
|
||||||
|
v-if="!isEditMode && !component.pendingEntity && component.composantId"
|
||||||
|
:to="machineId
|
||||||
|
? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } }
|
||||||
|
: `/component/${component.composantId}`"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
{{ component.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ component.name }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
v-if="component.pendingEntity"
|
v-if="component.pendingEntity"
|
||||||
@@ -34,232 +64,282 @@
|
|||||||
>
|
>
|
||||||
À remplir
|
À remplir
|
||||||
</button>
|
</button>
|
||||||
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
|
<span v-if="component.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ component.reference }}</span>
|
||||||
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}€</span>
|
<span v-if="component.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ component.referenceAuto }}</span>
|
||||||
|
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}€</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
|
<!-- Row 2: Metadata tags -->
|
||||||
|
<div
|
||||||
|
v-if="componentConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
|
||||||
|
class="flex flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
v-for="constructeur in componentConstructeursDisplay"
|
v-for="constructeur in componentConstructeursDisplay"
|
||||||
:key="constructeur.id"
|
:key="constructeur.id"
|
||||||
class="text-xs text-base-content/50"
|
class="text-[0.65rem] text-base-content/45"
|
||||||
>
|
>
|
||||||
{{ constructeur.name }}
|
{{ constructeur.name }}
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="displayProductName" class="badge badge-info badge-xs">
|
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
|
||||||
{{ displayProductName }}
|
{{ displayProductName }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Context field tags (consultation only) -->
|
||||||
|
<template v-if="!isEditMode">
|
||||||
|
<span
|
||||||
|
v-for="field in visibleContextFieldTags"
|
||||||
|
:key="field.name"
|
||||||
|
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
|
||||||
|
:class="contextFieldBadgeClass(field)"
|
||||||
|
>
|
||||||
|
{{ field.name }} : {{ field.value }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete button -->
|
||||||
<button
|
<button
|
||||||
v-if="showDelete"
|
v-if="showDelete"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
|
||||||
title="Supprimer ce composant"
|
title="Supprimer ce composant"
|
||||||
@click.stop="$emit('delete')"
|
@click.stop="$emit('delete')"
|
||||||
>
|
>
|
||||||
Supprimer
|
<IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expanded content -->
|
<!-- ═══ EXPANDED PANEL ═══ -->
|
||||||
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7">
|
<div v-show="!isCollapsed && !component.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
|
||||||
<!-- Info fields -->
|
|
||||||
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<!-- ── Section: Informations ── -->
|
||||||
<div class="form-control">
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="p-4">
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
|
<!-- Edit mode -->
|
||||||
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Nom</span></label>
|
||||||
|
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
|
||||||
|
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
|
||||||
|
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
class="w-full"
|
||||||
|
:model-value="componentConstructeurIds"
|
||||||
|
:initial-options="componentConstructeursDisplay"
|
||||||
|
@update:model-value="handleConstructeurChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Read-only mode -->
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Nom</p>
|
||||||
|
<p class="text-sm text-base-content font-medium">{{ component.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
|
||||||
|
<p class="text-sm text-base-content" :class="component.reference ? 'font-mono' : 'text-base-content/30'">{{ component.reference || '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="component.referenceAuto">
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
|
||||||
|
<p class="text-sm text-base-content font-mono">{{ component.referenceAuto }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
|
||||||
|
<p class="text-sm" :class="component.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ component.prix ? `${component.prix} €` : '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
|
||||||
|
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
|
||||||
|
<p
|
||||||
|
v-for="constructeur in componentConstructeursDisplay"
|
||||||
|
:key="constructeur.id"
|
||||||
|
class="text-sm text-base-content"
|
||||||
|
>
|
||||||
|
{{ constructeur.name }}
|
||||||
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
|
||||||
|
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
|
||||||
|
{{ formatConstructeurContact(constructeur) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-base-content/30">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
</div>
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
|
|
||||||
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
|
<!-- ── Section: Produit catalogue ── -->
|
||||||
|
<div v-if="displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-info/10 border-b border-info/20">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="p-4">
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
|
<div class="flex items-start justify-between gap-3">
|
||||||
<ConstructeurSelect
|
<div class="space-y-1.5">
|
||||||
class="w-full"
|
<p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
|
||||||
:model-value="componentConstructeurIds"
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
:initial-options="componentConstructeursDisplay"
|
<p
|
||||||
@update:model-value="handleConstructeurChange"
|
v-for="info in productInfoRows"
|
||||||
|
:key="info.label"
|
||||||
|
class="text-xs text-base-content/55"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="component.product?.id"
|
||||||
|
:to="`/product/${component.product.id}`"
|
||||||
|
class="btn btn-ghost btn-xs shrink-0 gap-1"
|
||||||
|
>
|
||||||
|
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
|
||||||
|
Voir
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<!-- Product documents -->
|
||||||
|
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200/50 space-y-2">
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/35">Documents du produit</p>
|
||||||
|
<div
|
||||||
|
v-for="document in productDocuments"
|
||||||
|
:key="document.id || document.path || document.name"
|
||||||
|
class="flex items-center justify-between gap-3 text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
|
||||||
|
<img
|
||||||
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
|
:src="document.fileUrl || document.path"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:alt="`Aperçu de ${document.name}`"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
v-else-if="shouldInlinePdf(document)"
|
||||||
|
:src="documentPreviewSrc(document)"
|
||||||
|
class="h-full w-full border-0 bg-white"
|
||||||
|
title="Aperçu PDF"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="documentIcon(document).component"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:class="documentIcon(document).colorClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="truncate text-base-content">{{ document.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="!canPreviewDocument(document)"
|
||||||
|
@click="openPreview(document)"
|
||||||
|
>
|
||||||
|
Consulter
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Section: Champs personnalisés ── -->
|
||||||
|
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<CustomFieldDisplay
|
||||||
|
:fields="displayedCustomFields"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:columns="2"
|
||||||
|
:show-header="false"
|
||||||
|
:with-top-border="false"
|
||||||
|
:editable="false"
|
||||||
|
@field-blur="updateComponentCustomField"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Read-only info -->
|
<div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
|
<div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
|
||||||
<div>
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
|
|
||||||
<p class="text-base-content">{{ component.name }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="p-4">
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Référence</p>
|
<CustomFieldDisplay
|
||||||
<p class="text-base-content">{{ component.reference || '—' }}</p>
|
:fields="mergedContextFields"
|
||||||
</div>
|
:is-edit-mode="isEditMode"
|
||||||
<div>
|
:columns="2"
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Prix</p>
|
:show-header="false"
|
||||||
<p class="text-base-content">{{ component.prix ? `${component.prix} €` : '—' }}</p>
|
:with-top-border="false"
|
||||||
</div>
|
:editable="true"
|
||||||
<div>
|
:emit-blur="false"
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p>
|
@field-input="queueContextCustomFieldUpdate"
|
||||||
<div v-if="componentConstructeursDisplay.length">
|
/>
|
||||||
<p
|
|
||||||
v-for="constructeur in componentConstructeursDisplay"
|
|
||||||
:key="constructeur.id"
|
|
||||||
class="text-base-content"
|
|
||||||
>
|
|
||||||
{{ constructeur.name }}
|
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
|
|
||||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
|
|
||||||
{{ formatConstructeurContact(constructeur) }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-base-content">—</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product -->
|
<!-- ── Section: Documents ── -->
|
||||||
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3">
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
|
||||||
<div class="space-y-1">
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
|
||||||
<p class="text-xs text-base-content/40">Produit catalogue</p>
|
|
||||||
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
|
|
||||||
<p
|
|
||||||
v-for="info in productInfoRows"
|
|
||||||
:key="info.label"
|
|
||||||
class="text-xs text-base-content/60"
|
|
||||||
>
|
|
||||||
{{ info.label }} : {{ info.value }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="component.product?.id"
|
|
||||||
:to="`/product/${component.product.id}`"
|
|
||||||
class="btn btn-ghost btn-xs shrink-0"
|
|
||||||
>
|
|
||||||
Voir le produit
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<!-- Product documents -->
|
|
||||||
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
|
|
||||||
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
|
|
||||||
<div
|
|
||||||
v-for="document in productDocuments"
|
|
||||||
:key="document.id || document.path || document.name"
|
|
||||||
class="flex items-center justify-between gap-3 text-xs"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
|
|
||||||
<img
|
|
||||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
|
||||||
:src="document.fileUrl || document.path"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
:alt="`Aperçu de ${document.name}`"
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
v-else-if="shouldInlinePdf(document)"
|
|
||||||
:src="documentPreviewSrc(document)"
|
|
||||||
class="h-full w-full border-0 bg-white"
|
|
||||||
title="Aperçu PDF"
|
|
||||||
/>
|
|
||||||
<component
|
|
||||||
v-else
|
|
||||||
:is="documentIcon(document).component"
|
|
||||||
class="h-4 w-4"
|
|
||||||
:class="documentIcon(document).colorClass"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="truncate text-base-content">{{ document.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
:disabled="!canPreviewDocument(document)"
|
|
||||||
@click="openPreview(document)"
|
|
||||||
>
|
|
||||||
Consulter
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
|
||||||
Télécharger
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom Fields -->
|
|
||||||
<CustomFieldDisplay
|
|
||||||
:fields="displayedCustomFields"
|
|
||||||
:is-edit-mode="isEditMode"
|
|
||||||
:columns="2"
|
|
||||||
title="Champs personnalisés item"
|
|
||||||
:editable="false"
|
|
||||||
@field-blur="updateComponentCustomField"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="mergedContextFields.length">
|
|
||||||
<div class="divider my-4 text-xs text-base-content/50">
|
|
||||||
Champs personnalisés machine
|
|
||||||
</div>
|
|
||||||
<CustomFieldDisplay
|
|
||||||
:fields="mergedContextFields"
|
|
||||||
:is-edit-mode="isEditMode"
|
|
||||||
:columns="2"
|
|
||||||
:show-header="false"
|
|
||||||
:with-top-border="false"
|
|
||||||
:editable="true"
|
|
||||||
:emit-blur="false"
|
|
||||||
@field-input="queueContextCustomFieldUpdate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Documents -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
|
|
||||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
||||||
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement...</p>
|
||||||
|
|
||||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
<DocumentUpload
|
||||||
Chargement...
|
v-if="isEditMode"
|
||||||
</p>
|
v-model="selectedFiles"
|
||||||
|
title="Déposer des fichiers pour ce composant"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents..."
|
||||||
|
@files-added="handleFilesAdded"
|
||||||
|
/>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentListInline
|
||||||
v-if="isEditMode"
|
:documents="componentDocuments"
|
||||||
v-model="selectedFiles"
|
:can-delete="isEditMode"
|
||||||
title="Déposer des fichiers pour ce composant"
|
:can-edit="isEditMode"
|
||||||
subtitle="Formats acceptés : PDF, images, documents..."
|
:delete-disabled="uploadingDocuments"
|
||||||
@files-added="handleFilesAdded"
|
empty-text="Aucun document lié à ce composant."
|
||||||
/>
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
<DocumentListInline
|
@delete="removeDocument"
|
||||||
:documents="componentDocuments"
|
/>
|
||||||
:can-delete="isEditMode"
|
</div>
|
||||||
:can-edit="isEditMode"
|
|
||||||
:delete-disabled="uploadingDocuments"
|
|
||||||
empty-text="Aucun document lié à ce composant."
|
|
||||||
@preview="openPreview"
|
|
||||||
@edit="openEditModal"
|
|
||||||
@delete="removeDocument"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Component Pieces (real MachinePieceLinks) -->
|
<!-- ── Section: Pièces du composant ── -->
|
||||||
<div v-if="linkedPieces.length > 0" class="space-y-2">
|
<div v-if="linkedPieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
Pièces du composant
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
|
||||||
</p>
|
Pièces du composant
|
||||||
<div class="space-y-2">
|
<span class="ml-1 text-base-content/25">({{ linkedPieces.length }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
<PieceItem
|
<PieceItem
|
||||||
v-for="piece in linkedPieces"
|
v-for="piece in linkedPieces"
|
||||||
:key="piece.id"
|
:key="piece.id"
|
||||||
@@ -273,12 +353,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Structure pieces (read-only, from composant definition) -->
|
<!-- ── Section: Pièces structure ── -->
|
||||||
<div v-if="structurePieces.length > 0" class="space-y-2">
|
<div v-if="structurePieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
Pièces incluses par défaut
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
|
||||||
</p>
|
Pièces incluses par défaut
|
||||||
<div class="space-y-2">
|
<span class="ml-1 text-base-content/25">({{ structurePieces.length }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
<PieceItem
|
<PieceItem
|
||||||
v-for="piece in structurePieces"
|
v-for="piece in structurePieces"
|
||||||
:key="piece.id"
|
:key="piece.id"
|
||||||
@@ -288,12 +371,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sub Components -->
|
<!-- ── Section: Sous-composants ── -->
|
||||||
<div v-if="childComponents.length > 0" class="space-y-2">
|
<div v-if="childComponents.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
Sous-composants
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
|
||||||
</p>
|
Sous-composants
|
||||||
<div class="space-y-2 pl-4 border-l-2 border-base-200">
|
<span class="ml-1 text-base-content/25">({{ childComponents.length }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
<ComponentItem
|
<ComponentItem
|
||||||
v-for="subComponent in childComponents"
|
v-for="subComponent in childComponents"
|
||||||
:key="subComponent.id"
|
:key="subComponent.id"
|
||||||
@@ -312,13 +398,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import PieceItem from './PieceItem.vue'
|
import PieceItem from './PieceItem.vue'
|
||||||
import DocumentUpload from './DocumentUpload.vue'
|
import DocumentUpload from './DocumentUpload.vue'
|
||||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
import IconLucideExternalLink from '~icons/lucide/external-link'
|
||||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import {
|
import {
|
||||||
@@ -335,13 +423,11 @@ import {
|
|||||||
} from '~/shared/utils/documentDisplayUtils'
|
} from '~/shared/utils/documentDisplayUtils'
|
||||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import {
|
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
|
||||||
mergeFieldDefinitionsWithValues,
|
|
||||||
dedupeMergedFields,
|
const route = useRoute()
|
||||||
resolveCustomFieldId,
|
const machineId = computed(() => route.params.id as string | undefined)
|
||||||
resolveFieldId,
|
|
||||||
} from '~/shared/utils/entityCustomFieldLogic'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
component: { type: Object, required: true },
|
component: { type: Object, required: true },
|
||||||
@@ -377,25 +463,99 @@ const {
|
|||||||
} = useEntityProductDisplay({ entity: () => props.component })
|
} = useEntityProductDisplay({ entity: () => props.component })
|
||||||
|
|
||||||
const {
|
const {
|
||||||
displayedCustomFields,
|
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||||
updateCustomField: updateComponentCustomField,
|
upsertCustomFieldValue,
|
||||||
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
} = useCustomFields()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
// Parent already pre-merges standalone custom fields into props.component.customFields
|
||||||
|
const displayedCustomFields = computed(() => {
|
||||||
|
const fields = props.component?.customFields
|
||||||
|
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateComponentCustomField = async (field) => {
|
||||||
|
if (!field || field.readOnly) return
|
||||||
|
|
||||||
|
const e = props.component
|
||||||
|
const fieldValueId = field.customFieldValueId
|
||||||
|
|
||||||
|
if (fieldValueId) {
|
||||||
|
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
|
||||||
|
} else {
|
||||||
|
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e?.id) {
|
||||||
|
showError('Impossible de créer la valeur pour ce champ')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = field.customFieldId ? undefined : {
|
||||||
|
customFieldName: field.name,
|
||||||
|
customFieldType: field.type,
|
||||||
|
customFieldRequired: field.required,
|
||||||
|
customFieldOptions: field.options,
|
||||||
|
}
|
||||||
|
const result = await upsertCustomFieldValue(
|
||||||
|
field.customFieldId,
|
||||||
|
'composant',
|
||||||
|
e.id,
|
||||||
|
field.value ?? '',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const newValue = result.data
|
||||||
|
if (newValue?.id) {
|
||||||
|
field.customFieldValueId = newValue.id
|
||||||
|
field.value = newValue.value ?? field.value ?? ''
|
||||||
|
if (newValue.customField?.id) {
|
||||||
|
field.customFieldId = newValue.customField.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showSuccess(`Champ "${field.name}" créé avec succès`)
|
||||||
|
} else {
|
||||||
|
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context fields are NOT pre-merged — merge locally
|
||||||
const mergedContextFields = computed(() => {
|
const mergedContextFields = computed(() => {
|
||||||
const definitions = props.component?.contextCustomFields ?? []
|
const definitions = props.component?.contextCustomFields ?? []
|
||||||
const values = props.component?.contextCustomFieldValues ?? []
|
const values = props.component?.contextCustomFieldValues ?? []
|
||||||
if (!definitions.length && !values.length) return []
|
if (!definitions.length && !values.length) return []
|
||||||
return dedupeMergedFields(
|
return mergeDefinitionsWithValues(definitions, values)
|
||||||
mergeFieldDefinitionsWithValues(definitions, values),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Context fields shown as tags on the header (consultation mode)
|
||||||
|
const visibleContextFieldTags = computed(() =>
|
||||||
|
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CONTEXT_FIELD_COLORS = [
|
||||||
|
'bg-secondary/25 text-secondary border border-secondary/35',
|
||||||
|
'bg-accent/25 text-accent border border-accent/35',
|
||||||
|
'bg-info/25 text-info border border-info/35',
|
||||||
|
'bg-success/25 text-success border border-success/35',
|
||||||
|
'bg-warning/25 text-warning border border-warning/35',
|
||||||
|
]
|
||||||
|
|
||||||
|
const contextFieldBadgeClass = (field: any) => {
|
||||||
|
const idx = visibleContextFieldTags.value.indexOf(field)
|
||||||
|
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
const queueContextCustomFieldUpdate = (field, value) => {
|
const queueContextCustomFieldUpdate = (field, value) => {
|
||||||
const linkId = props.component?.linkId
|
const linkId = props.component?.linkId
|
||||||
if (!linkId || !field) return
|
if (!linkId || !field) return
|
||||||
|
|
||||||
const customFieldId = resolveCustomFieldId(field)
|
const customFieldId = field.customFieldId
|
||||||
const customFieldValueId = resolveFieldId(field)
|
const customFieldValueId = field.customFieldValueId
|
||||||
if (!customFieldId && !customFieldValueId) return
|
if (!customFieldId && !customFieldValueId) return
|
||||||
|
|
||||||
field.value = value
|
field.value = value
|
||||||
@@ -405,7 +565,7 @@ const queueContextCustomFieldUpdate = (field, value) => {
|
|||||||
fieldId: customFieldId,
|
fieldId: customFieldId,
|
||||||
customFieldValueId,
|
customFieldValueId,
|
||||||
value: value ?? '',
|
value: value ?? '',
|
||||||
fieldName: field.name || field.customField?.name || 'Champ contextuel',
|
fieldName: field.name || 'Champ contextuel',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md">
|
||||||
Retour au catalogue
|
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
</button>
|
{{ backLabel }}
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -25,8 +26,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||||
import IconLucideEye from '~icons/lucide/eye'
|
import IconLucideEye from '~icons/lucide/eye'
|
||||||
|
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
||||||
|
|
||||||
const router = useRouter()
|
const route = useRoute()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string
|
title: string
|
||||||
@@ -34,18 +36,24 @@ const props = defineProps<{
|
|||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
backLink: string
|
backLink: string
|
||||||
|
backLinkLabel?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'toggle-edit': []
|
'toggle-edit': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function goBack() {
|
const backDestination = computed(() => {
|
||||||
if (window.history.length > 1) {
|
if (route.query.from === 'machine' && route.query.machineId) {
|
||||||
router.back()
|
return `/machine/${route.query.machineId}`
|
||||||
}
|
}
|
||||||
else {
|
return props.backLink
|
||||||
navigateTo(props.backLink)
|
})
|
||||||
|
|
||||||
|
const backLabel = computed(() => {
|
||||||
|
if (route.query.from === 'machine') {
|
||||||
|
return 'Retour à la machine'
|
||||||
}
|
}
|
||||||
}
|
return props.backLinkLabel ?? 'Retour au catalogue'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div>
|
||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
@@ -13,304 +13,346 @@
|
|||||||
@updated="handleDocumentUpdated"
|
@updated="handleDocumentUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
<!-- ═══ HEADER BAR ═══ -->
|
||||||
<div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot || piece.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'">
|
<div
|
||||||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
|
||||||
<button
|
:class="[
|
||||||
type="button"
|
piece._emptySlot || piece.pendingEntity
|
||||||
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
? 'bg-error/10 border border-error/40 hover:border-error/60'
|
||||||
:class="{ 'rotate-90': !isCollapsed }"
|
: 'bg-base-200/70 border border-base-300/30 hover:border-base-300/60 hover:bg-base-200',
|
||||||
:aria-expanded="!isCollapsed"
|
!isCollapsed ? 'shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
|
||||||
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
|
]"
|
||||||
@click="toggleCollapse"
|
@click="toggleCollapse"
|
||||||
>
|
>
|
||||||
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
<!-- Chevron -->
|
||||||
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
|
<div
|
||||||
</button>
|
class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
|
||||||
<div class="flex-1 min-w-0">
|
:class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
|
||||||
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
|
>
|
||||||
{{ pieceData.name }}
|
<IconLucideChevronRight
|
||||||
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1">— manquant</span>
|
class="w-3.5 h-3.5 transition-transform duration-200"
|
||||||
<button
|
:class="[
|
||||||
v-if="piece.pendingEntity"
|
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
|
||||||
type="button"
|
]"
|
||||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors ml-1"
|
aria-hidden="true"
|
||||||
title="Cliquer pour associer un item"
|
/>
|
||||||
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<!-- Row 1: Name + identifiers -->
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 class="text-sm font-bold tracking-tight truncate" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
|
||||||
|
:to="machineId
|
||||||
|
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
|
||||||
|
: `/piece/${piece.pieceId}`"
|
||||||
|
class="hover:text-primary transition-colors"
|
||||||
|
@click.stop
|
||||||
>
|
>
|
||||||
À remplir
|
{{ pieceData.name }}
|
||||||
</button>
|
</NuxtLink>
|
||||||
<span
|
<template v-else>{{ pieceData.name }}</template>
|
||||||
v-if="displayQuantity > 1"
|
|
||||||
class="text-sm font-normal text-base-content/60 ml-1"
|
|
||||||
>
|
|
||||||
×{{ displayQuantity }}
|
|
||||||
</span>
|
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<span v-if="piece._emptySlot" class="text-[0.65rem] font-bold text-error bg-error/10 px-1.5 py-0.5 rounded">manquant</span>
|
||||||
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
|
<button
|
||||||
Rattachée à {{ piece.parentComponentName }}
|
v-if="piece.pendingEntity"
|
||||||
</span>
|
type="button"
|
||||||
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
|
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
|
||||||
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
title="Cliquer pour associer un item"
|
||||||
<template v-if="pieceConstructeursDisplay.length">
|
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
|
||||||
<span
|
>
|
||||||
v-for="constructeur in pieceConstructeursDisplay"
|
À remplir
|
||||||
:key="constructeur.id"
|
</button>
|
||||||
class="badge badge-outline badge-sm"
|
<span v-if="displayQuantity > 1" class="text-[0.65rem] font-bold text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">×{{ displayQuantity }}</span>
|
||||||
>
|
<span v-if="pieceData.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ pieceData.reference }}</span>
|
||||||
{{ constructeur.name }}
|
<span v-if="pieceData.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
|
<span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}€</span>
|
||||||
({{ supplierReferenceMap.get(constructeur.id) }})
|
</div>
|
||||||
</span>
|
|
||||||
</span>
|
<!-- Row 2: Metadata tags -->
|
||||||
</template>
|
<div
|
||||||
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}€</span>
|
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
|
||||||
|
class="flex flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
|
||||||
|
{{ piece.parentComponentName }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="constructeur in pieceConstructeursDisplay"
|
||||||
|
:key="constructeur.id"
|
||||||
|
class="text-[0.65rem] text-base-content/45"
|
||||||
|
>
|
||||||
|
{{ constructeur.name }}
|
||||||
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
|
||||||
|
{{ displayProductName }}
|
||||||
|
</span>
|
||||||
|
<!-- Context field tags (consultation only) -->
|
||||||
|
<template v-if="!isEditMode">
|
||||||
<span
|
<span
|
||||||
v-if="displayProductName"
|
v-for="field in visibleContextFieldTags"
|
||||||
class="badge badge-info badge-sm"
|
:key="field.name"
|
||||||
|
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
|
||||||
|
:class="contextFieldBadgeClass(field)"
|
||||||
>
|
>
|
||||||
Produit : {{ displayProductName }}
|
{{ field.name }} : {{ field.value }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete button -->
|
||||||
<button
|
<button
|
||||||
v-if="showDelete"
|
v-if="showDelete"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
|
||||||
title="Supprimer cette pièce"
|
title="Supprimer cette pièce"
|
||||||
@click="$emit('delete')"
|
@click.stop="$emit('delete')"
|
||||||
>
|
>
|
||||||
Supprimer
|
<IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4">
|
<!-- ═══ EXPANDED PANEL ═══ -->
|
||||||
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
|
<div v-show="!isCollapsed && !piece.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<div v-if="isEditMode" class="form-control">
|
<!-- ── Section: Informations ── -->
|
||||||
<label class="label">
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<span class="label-text text-sm">Quantité</span>
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
</label>
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
|
||||||
<input
|
</div>
|
||||||
v-model.number="pieceData.quantity"
|
<div class="p-4">
|
||||||
type="number"
|
<!-- Edit mode -->
|
||||||
min="1"
|
<div v-if="isEditMode" class="space-y-3">
|
||||||
step="1"
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
class="input input-bordered input-sm md:input-md w-24"
|
<div class="form-control">
|
||||||
@blur="updatePiece"
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Quantité</span></label>
|
||||||
/>
|
<input
|
||||||
</div>
|
v-model.number="pieceData.quantity"
|
||||||
<div v-else-if="displayQuantity > 1">
|
type="number"
|
||||||
<span class="font-medium">Quantité:</span>
|
min="1"
|
||||||
<span class="ml-2">{{ displayQuantity }}</span>
|
step="1"
|
||||||
</div>
|
class="input input-bordered input-sm w-full"
|
||||||
<div>
|
@blur="updatePiece"
|
||||||
<span class="font-medium">Référence:</span>
|
/>
|
||||||
<input
|
</div>
|
||||||
v-if="isEditMode"
|
<div class="form-control">
|
||||||
:id="`piece-reference-${piece.id}`"
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
|
||||||
v-model="pieceData.reference"
|
<input
|
||||||
type="text"
|
:id="`piece-reference-${piece.id}`"
|
||||||
class="input input-sm input-bordered ml-2"
|
v-model="pieceData.reference"
|
||||||
@blur="updatePiece"
|
type="text"
|
||||||
/>
|
class="input input-bordered input-sm w-full"
|
||||||
<span v-else class="ml-2">{{
|
@blur="updatePiece"
|
||||||
pieceData.reference || "Non définie"
|
/>
|
||||||
}}</span>
|
</div>
|
||||||
</div>
|
<div class="form-control">
|
||||||
<div v-if="pieceData.referenceAuto">
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
|
||||||
<span class="font-medium">Référence auto:</span>
|
<input
|
||||||
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
|
:id="`piece-prix-${piece.id}`"
|
||||||
</div>
|
v-model="pieceData.prix"
|
||||||
<div>
|
type="number"
|
||||||
<span class="font-medium">Fournisseur:</span>
|
step="0.01"
|
||||||
<div v-if="!isEditMode" class="ml-2">
|
class="input input-bordered input-sm w-full"
|
||||||
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
|
@blur="updatePiece"
|
||||||
<div
|
/>
|
||||||
v-for="constructeur in pieceConstructeursDisplay"
|
</div>
|
||||||
:key="constructeur.id"
|
</div>
|
||||||
class="flex flex-col"
|
<div class="form-control">
|
||||||
>
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
|
||||||
<span class="font-medium">
|
<ConstructeurSelect
|
||||||
{{ constructeur.name }}
|
class="w-full"
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60">
|
:model-value="pieceConstructeurIds"
|
||||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
:initial-options="pieceConstructeursDisplay"
|
||||||
</span>
|
placeholder="Sélectionner un ou plusieurs fournisseurs..."
|
||||||
</span>
|
@update:model-value="handleConstructeurChange"
|
||||||
<span
|
/>
|
||||||
v-if="formatConstructeurContact(constructeur)"
|
|
||||||
class="text-xs text-base-content/50"
|
|
||||||
>
|
|
||||||
{{ formatConstructeurContact(constructeur) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="font-medium">
|
<!-- Read-only mode -->
|
||||||
Non défini
|
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||||
</span>
|
<div v-if="displayQuantity > 1">
|
||||||
</div>
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Quantité</p>
|
||||||
<ConstructeurSelect
|
<p class="text-sm text-base-content font-medium">{{ displayQuantity }}</p>
|
||||||
v-else
|
</div>
|
||||||
class="w-full"
|
<div>
|
||||||
:model-value="pieceConstructeurIds"
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
|
||||||
:initial-options="pieceConstructeursDisplay"
|
<p class="text-sm" :class="pieceData.reference ? 'text-base-content font-mono' : 'text-base-content/30'">{{ pieceData.reference || '—' }}</p>
|
||||||
placeholder="Sélectionner un ou plusieurs fournisseurs..."
|
</div>
|
||||||
@update:model-value="handleConstructeurChange"
|
<div v-if="pieceData.referenceAuto">
|
||||||
/>
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
|
||||||
</div>
|
<p class="text-sm text-base-content font-mono">{{ pieceData.referenceAuto }}</p>
|
||||||
<div>
|
</div>
|
||||||
<span class="font-medium">Prix:</span>
|
<div>
|
||||||
<input
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
|
||||||
v-if="isEditMode"
|
<p class="text-sm" :class="pieceData.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ pieceData.prix ? `${pieceData.prix} €` : '—' }}</p>
|
||||||
:id="`piece-prix-${piece.id}`"
|
</div>
|
||||||
v-model="pieceData.prix"
|
<div>
|
||||||
type="number"
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
|
||||||
step="0.01"
|
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
|
||||||
class="input input-sm input-bordered ml-2"
|
<p
|
||||||
@blur="updatePiece"
|
v-for="constructeur in pieceConstructeursDisplay"
|
||||||
/>
|
:key="constructeur.id"
|
||||||
<span v-else class="ml-2">{{
|
class="text-sm text-base-content"
|
||||||
pieceData.prix ? `${pieceData.prix}€` : "Non défini"
|
>
|
||||||
}}</span>
|
{{ constructeur.name }}
|
||||||
</div>
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
|
||||||
<div>
|
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||||
<span class="font-medium">Produit catalogue:</span>
|
</span>
|
||||||
<div v-if="isEditMode" class="mt-2 space-y-2">
|
<span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
|
||||||
<ProductSelect
|
{{ formatConstructeurContact(constructeur) }}
|
||||||
:model-value="pieceData.productId"
|
</span>
|
||||||
placeholder="Associer un produit…"
|
</p>
|
||||||
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
|
</div>
|
||||||
@update:modelValue="handleProductChange"
|
<p v-else class="text-sm text-base-content/30">—</p>
|
||||||
/>
|
</div>
|
||||||
<div
|
|
||||||
v-if="selectedProduct"
|
|
||||||
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
|
|
||||||
>
|
|
||||||
<p class="text-sm font-semibold text-base-content">
|
|
||||||
{{ selectedProduct.name }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-for="info in productInfoRows"
|
|
||||||
:key="info.label"
|
|
||||||
class="flex flex-wrap gap-1"
|
|
||||||
>
|
|
||||||
<span class="font-semibold">{{ info.label }} :</span>
|
|
||||||
<span>{{ info.value }}</span>
|
|
||||||
</p>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="selectedProduct.id"
|
|
||||||
:to="`/product/${selectedProduct.id}`"
|
|
||||||
class="link link-primary text-xs"
|
|
||||||
>
|
|
||||||
Ouvrir la fiche produit
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-xs text-base-content/60">
|
|
||||||
Aucun produit associé.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-2">
|
</div>
|
||||||
<div v-if="displayProduct" class="space-y-1">
|
|
||||||
<p class="font-medium text-base-content">
|
<!-- ── Section: Produit catalogue ── -->
|
||||||
{{ displayProductName || 'Produit catalogue' }}
|
<div v-if="isEditMode || displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
</p>
|
<div class="px-4 py-2 bg-info/10 border-b border-info/20">
|
||||||
<p
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
|
||||||
v-for="info in productInfoRows"
|
</div>
|
||||||
:key="info.label"
|
<div class="p-4">
|
||||||
class="text-xs text-base-content/70"
|
<!-- Edit mode -->
|
||||||
|
<div v-if="isEditMode" class="space-y-3">
|
||||||
|
<ProductSelect
|
||||||
|
:model-value="pieceData.productId"
|
||||||
|
placeholder="Associer un produit…"
|
||||||
|
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
|
||||||
|
@update:modelValue="handleProductChange"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="selectedProduct"
|
||||||
|
class="rounded-lg border border-base-200/60 bg-base-200/20 p-3 space-y-1.5"
|
||||||
>
|
>
|
||||||
<span class="font-semibold">{{ info.label }} :</span>
|
<p class="text-sm font-bold text-base-content">{{ selectedProduct.name }}</p>
|
||||||
<span class="ml-1">{{ info.value }}</span>
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
</p>
|
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
|
||||||
|
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink v-if="selectedProduct.id" :to="`/product/${selectedProduct.id}`" class="link link-primary text-xs">
|
||||||
|
Ouvrir la fiche produit
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Read-only mode -->
|
||||||
|
<div v-else-if="displayProduct">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
|
||||||
|
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="piece.product?.id || piece.productId"
|
||||||
|
:to="`/product/${piece.product?.id || piece.productId}`"
|
||||||
|
class="btn btn-ghost btn-xs shrink-0 gap-1"
|
||||||
|
>
|
||||||
|
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
|
||||||
|
Voir
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
<ProductDocumentsInline
|
<ProductDocumentsInline
|
||||||
|
v-if="productDocuments.length"
|
||||||
|
class="mt-3 pt-3 border-t border-base-200/50"
|
||||||
:documents="productDocuments"
|
:documents="productDocuments"
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="font-medium">
|
|
||||||
Non défini
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- ── Section: Champs personnalisés item ── -->
|
||||||
|
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<CustomFieldDisplay
|
||||||
|
:fields="displayedCustomFields"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:show-header="false"
|
||||||
|
:with-top-border="false"
|
||||||
|
:editable="false"
|
||||||
|
@field-input="handleCustomFieldInput"
|
||||||
|
@field-blur="handleCustomFieldBlur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Champs personnalisés de la pièce -->
|
<!-- ── Section: Champs personnalisés machine ── -->
|
||||||
<CustomFieldDisplay
|
<div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
:fields="displayedCustomFields"
|
<div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
|
||||||
:is-edit-mode="isEditMode"
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
|
||||||
title="Champs personnalisés item"
|
</div>
|
||||||
:editable="false"
|
<div class="p-4">
|
||||||
@field-input="handleCustomFieldInput"
|
<CustomFieldDisplay
|
||||||
@field-blur="handleCustomFieldBlur"
|
:fields="mergedContextFields"
|
||||||
/>
|
:is-edit-mode="isEditMode"
|
||||||
|
:columns="2"
|
||||||
<template v-if="mergedContextFields.length">
|
:show-header="false"
|
||||||
<div class="divider my-4 text-xs text-base-content/50">
|
:with-top-border="false"
|
||||||
Champs personnalisés machine
|
:editable="true"
|
||||||
</div>
|
:emit-blur="false"
|
||||||
<CustomFieldDisplay
|
@field-input="queueContextCustomFieldUpdate"
|
||||||
:fields="mergedContextFields"
|
/>
|
||||||
:is-edit-mode="isEditMode"
|
</div>
|
||||||
:columns="2"
|
|
||||||
:show-header="false"
|
|
||||||
:with-top-border="false"
|
|
||||||
:editable="true"
|
|
||||||
:emit-blur="false"
|
|
||||||
@field-input="queueContextCustomFieldUpdate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
|
|
||||||
<span
|
|
||||||
v-if="isEditMode && selectedFiles.length"
|
|
||||||
class="badge badge-outline"
|
|
||||||
>
|
|
||||||
{{ selectedFiles.length }} fichier{{
|
|
||||||
selectedFiles.length > 1 ? "s" : ""
|
|
||||||
}}
|
|
||||||
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
<!-- ── Section: Documents ── -->
|
||||||
Chargement des documents...
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
</p>
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
|
||||||
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
||||||
|
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement des documents...</p>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-if="isEditMode"
|
v-if="isEditMode"
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer des fichiers pour cette pièce"
|
title="Déposer des fichiers pour cette pièce"
|
||||||
subtitle="Formats acceptés : PDF, images, documents..."
|
subtitle="Formats acceptés : PDF, images, documents..."
|
||||||
@files-added="handleFilesAdded"
|
@files-added="handleFilesAdded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentListInline
|
<DocumentListInline
|
||||||
:documents="pieceDocuments"
|
:documents="pieceDocuments"
|
||||||
:can-delete="isEditMode"
|
:can-delete="isEditMode"
|
||||||
:can-edit="isEditMode"
|
:can-edit="isEditMode"
|
||||||
:delete-disabled="uploadingDocuments"
|
:delete-disabled="uploadingDocuments"
|
||||||
empty-text="Aucun document lié à cette pièce."
|
empty-text="Aucun document lié à cette pièce."
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
@edit="openEditModal"
|
@edit="openEditModal"
|
||||||
@delete="removeDocument"
|
@delete="removeDocument"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { reactive, ref, onMounted, watch, computed } from 'vue'
|
import { reactive, ref, onMounted, watch, computed } from 'vue'
|
||||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||||
import ProductSelect from '~/components/ProductSelect.vue'
|
import ProductSelect from '~/components/ProductSelect.vue'
|
||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
import IconLucideExternalLink from '~icons/lucide/external-link'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import {
|
import {
|
||||||
@@ -319,16 +361,13 @@ import {
|
|||||||
uniqueConstructeurIds,
|
uniqueConstructeurIds,
|
||||||
parseConstructeurLinksFromApi,
|
parseConstructeurLinksFromApi,
|
||||||
} from '~/shared/constructeurUtils'
|
} from '~/shared/constructeurUtils'
|
||||||
import {
|
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
|
||||||
resolveFieldId,
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
resolveFieldReadOnly,
|
|
||||||
resolveCustomFieldId,
|
|
||||||
mergeFieldDefinitionsWithValues,
|
|
||||||
dedupeMergedFields,
|
|
||||||
} from '~/shared/utils/entityCustomFieldLogic'
|
|
||||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
|
||||||
|
const route = useRoute()
|
||||||
|
const machineId = computed(() => route.params.id as string | undefined)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
piece: { type: Object, required: true },
|
piece: { type: Object, required: true },
|
||||||
@@ -392,25 +431,99 @@ const {
|
|||||||
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
|
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
|
||||||
|
|
||||||
const {
|
const {
|
||||||
displayedCustomFields,
|
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||||
updateCustomField,
|
upsertCustomFieldValue,
|
||||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
} = useCustomFields()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
// Parent already pre-merges standalone custom fields into props.piece.customFields
|
||||||
|
const displayedCustomFields = computed(() => {
|
||||||
|
const fields = props.piece?.customFields
|
||||||
|
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateCustomField = async (field) => {
|
||||||
|
if (!field || field.readOnly) return
|
||||||
|
|
||||||
|
const e = props.piece
|
||||||
|
const fieldValueId = field.customFieldValueId
|
||||||
|
|
||||||
|
if (fieldValueId) {
|
||||||
|
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
|
||||||
|
} else {
|
||||||
|
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!e?.id) {
|
||||||
|
showError('Impossible de créer la valeur pour ce champ')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = field.customFieldId ? undefined : {
|
||||||
|
customFieldName: field.name,
|
||||||
|
customFieldType: field.type,
|
||||||
|
customFieldRequired: field.required,
|
||||||
|
customFieldOptions: field.options,
|
||||||
|
}
|
||||||
|
const result = await upsertCustomFieldValue(
|
||||||
|
field.customFieldId,
|
||||||
|
'piece',
|
||||||
|
e.id,
|
||||||
|
field.value ?? '',
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const newValue = result.data
|
||||||
|
if (newValue?.id) {
|
||||||
|
field.customFieldValueId = newValue.id
|
||||||
|
field.value = newValue.value ?? field.value ?? ''
|
||||||
|
if (newValue.customField?.id) {
|
||||||
|
field.customFieldId = newValue.customField.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showSuccess(`Champ "${field.name}" créé avec succès`)
|
||||||
|
} else {
|
||||||
|
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context fields are NOT pre-merged — merge locally
|
||||||
const mergedContextFields = computed(() => {
|
const mergedContextFields = computed(() => {
|
||||||
const definitions = props.piece?.contextCustomFields ?? []
|
const definitions = props.piece?.contextCustomFields ?? []
|
||||||
const values = props.piece?.contextCustomFieldValues ?? []
|
const values = props.piece?.contextCustomFieldValues ?? []
|
||||||
if (!definitions.length && !values.length) return []
|
if (!definitions.length && !values.length) return []
|
||||||
return dedupeMergedFields(
|
return mergeDefinitionsWithValues(definitions, values)
|
||||||
mergeFieldDefinitionsWithValues(definitions, values),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Context fields shown as tags on the header (consultation mode)
|
||||||
|
const visibleContextFieldTags = computed(() =>
|
||||||
|
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CONTEXT_FIELD_COLORS = [
|
||||||
|
'bg-secondary/25 text-secondary border border-secondary/35',
|
||||||
|
'bg-accent/25 text-accent border border-accent/35',
|
||||||
|
'bg-info/25 text-info border border-info/35',
|
||||||
|
'bg-success/25 text-success border border-success/35',
|
||||||
|
'bg-warning/25 text-warning border border-warning/35',
|
||||||
|
]
|
||||||
|
|
||||||
|
const contextFieldBadgeClass = (field) => {
|
||||||
|
const idx = visibleContextFieldTags.value.indexOf(field)
|
||||||
|
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
const queueContextCustomFieldUpdate = (field, value) => {
|
const queueContextCustomFieldUpdate = (field, value) => {
|
||||||
const linkId = props.piece?.linkId
|
const linkId = props.piece?.linkId
|
||||||
if (!linkId || !field) return
|
if (!linkId || !field) return
|
||||||
|
|
||||||
const customFieldId = resolveCustomFieldId(field)
|
const customFieldId = field.customFieldId
|
||||||
const customFieldValueId = resolveFieldId(field)
|
const customFieldValueId = field.customFieldValueId
|
||||||
if (!customFieldId && !customFieldValueId) return
|
if (!customFieldId && !customFieldValueId) return
|
||||||
|
|
||||||
field.value = value
|
field.value = value
|
||||||
@@ -420,7 +533,7 @@ const queueContextCustomFieldUpdate = (field, value) => {
|
|||||||
fieldId: customFieldId,
|
fieldId: customFieldId,
|
||||||
customFieldValueId,
|
customFieldValueId,
|
||||||
value: value ?? '',
|
value: value ?? '',
|
||||||
fieldName: field.name || field.customField?.name || 'Champ contextuel',
|
fieldName: field.name || 'Champ contextuel',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,8 +657,8 @@ const handleProductChange = async (value) => {
|
|||||||
|
|
||||||
// --- Custom field event handlers ---
|
// --- Custom field event handlers ---
|
||||||
const handleCustomFieldInput = (field, value) => {
|
const handleCustomFieldInput = (field, value) => {
|
||||||
if (resolveFieldReadOnly(field)) return
|
if (field.readOnly) return
|
||||||
const fieldValueId = resolveFieldId(field)
|
const fieldValueId = field.customFieldValueId
|
||||||
if (!fieldValueId) return
|
if (!fieldValueId) return
|
||||||
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
|
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
|
||||||
if (fieldValue) fieldValue.value = value
|
if (fieldValue) fieldValue.value = value
|
||||||
@@ -553,7 +666,7 @@ const handleCustomFieldInput = (field, value) => {
|
|||||||
|
|
||||||
const handleCustomFieldBlur = async (field) => {
|
const handleCustomFieldBlur = async (field) => {
|
||||||
await updateCustomField(field)
|
await updateCustomField(field)
|
||||||
const cfId = field?.customFieldId || field?.customField?.id || null
|
const cfId = field?.customFieldId || null
|
||||||
if (cfId || field?.customFieldValueId) {
|
if (cfId || field?.customFieldValueId) {
|
||||||
emit('custom-field-update', {
|
emit('custom-field-update', {
|
||||||
fieldId: cfId,
|
fieldId: cfId,
|
||||||
@@ -626,12 +739,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
pieceData.name = props.piece.name || ''
|
|
||||||
pieceData.reference = props.piece.reference || ''
|
|
||||||
pieceData.prix = props.piece.prix || ''
|
|
||||||
pieceData.quantity = props.piece.quantity ?? 1
|
|
||||||
loadProducts().catch(() => {})
|
loadProducts().catch(() => {})
|
||||||
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
|
|
||||||
if (!props.piece.documents?.length) refreshDocuments()
|
if (!props.piece.documents?.length) refreshDocuments()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="space-y-3">
|
<section v-if="!hideProducts" class="space-y-3">
|
||||||
<header>
|
<header>
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
Produits inclus par défaut
|
Produits inclus par défaut
|
||||||
@@ -166,6 +166,7 @@ defineOptions({ name: 'PieceModelStructureEditor' })
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: PieceModelStructure | null
|
modelValue?: PieceModelStructure | null
|
||||||
|
hideProducts?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="alert toast-card shadow-md px-3 py-2 text-sm"
|
class="alert toast-card relative shadow-md px-3 py-2 text-sm overflow-hidden"
|
||||||
:class="getToastClasses(toast.type)"
|
:class="getToastClasses(toast.type)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -54,13 +54,20 @@
|
|||||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar for auto-dismiss toasts -->
|
||||||
|
<div
|
||||||
|
v-if="toast.duration > 0"
|
||||||
|
class="absolute bottom-0 left-0 h-0.5 bg-current opacity-30 rounded-full"
|
||||||
|
:style="{ animation: `toast-progress ${toast.duration}ms linear forwards` }"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import IconLucideCheck from '~icons/lucide/check'
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
import IconLucideX from '~icons/lucide/x'
|
import IconLucideX from '~icons/lucide/x'
|
||||||
@@ -70,7 +77,7 @@ import IconLucideInfo from '~icons/lucide/info'
|
|||||||
|
|
||||||
const { toasts, removeToast } = useToast()
|
const { toasts, removeToast } = useToast()
|
||||||
|
|
||||||
const getToastClasses = (type) => {
|
const getToastClasses = (type: ToastType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success':
|
case 'success':
|
||||||
return 'alert-success text-success-content'
|
return 'alert-success text-success-content'
|
||||||
@@ -111,4 +118,9 @@ const getToastClasses = (type) => {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes toast-progress {
|
||||||
|
from { width: 100%; }
|
||||||
|
to { width: 0%; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,15 +9,15 @@
|
|||||||
<div :class="layoutClass">
|
<div :class="layoutClass">
|
||||||
<div
|
<div
|
||||||
v-for="(field, index) in fields"
|
v-for="(field, index) in fields"
|
||||||
:key="resolveFieldKey(field, index)"
|
:key="fieldKey(field, index)"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
>
|
>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-sm">{{
|
<span class="label-text text-sm">{{
|
||||||
resolveFieldName(field)
|
field.name
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
v-if="resolveFieldRequired(field)"
|
v-if="field.required"
|
||||||
class="label-text-alt text-error"
|
class="label-text-alt text-error"
|
||||||
>*</span>
|
>*</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -26,32 +26,32 @@
|
|||||||
<template v-if="isFieldEditable(field)">
|
<template v-if="isFieldEditable(field)">
|
||||||
<!-- Champ de type TEXT -->
|
<!-- Champ de type TEXT -->
|
||||||
<input
|
<input
|
||||||
v-if="resolveFieldType(field) === 'text'"
|
v-if="field.type === 'text'"
|
||||||
:value="field.value ?? ''"
|
:value="field.value ?? ''"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:required="resolveFieldRequired(field)"
|
:required="field.required"
|
||||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||||
@blur="onBlur(field)"
|
@blur="onBlur(field)"
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- Champ de type NUMBER -->
|
<!-- Champ de type NUMBER -->
|
||||||
<input
|
<input
|
||||||
v-else-if="resolveFieldType(field) === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
:value="field.value ?? ''"
|
:value="field.value ?? ''"
|
||||||
type="number"
|
type="number"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:required="resolveFieldRequired(field)"
|
:required="field.required"
|
||||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||||
@blur="onBlur(field)"
|
@blur="onBlur(field)"
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- Champ de type SELECT -->
|
<!-- Champ de type SELECT -->
|
||||||
<select
|
<select
|
||||||
v-else-if="resolveFieldType(field) === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
:value="field.value ?? ''"
|
:value="field.value ?? ''"
|
||||||
class="select select-bordered select-sm"
|
class="select select-bordered select-sm"
|
||||||
:required="resolveFieldRequired(field)"
|
:required="field.required"
|
||||||
@change="onInput(field, ($event.target as HTMLSelectElement).value)"
|
@change="onInput(field, ($event.target as HTMLSelectElement).value)"
|
||||||
@blur="onBlur(field)"
|
@blur="onBlur(field)"
|
||||||
>
|
>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
Sélectionner...
|
Sélectionner...
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="option in resolveFieldOptions(field)"
|
v-for="option in field.options"
|
||||||
:key="option"
|
:key="option"
|
||||||
:value="option"
|
:value="option"
|
||||||
>
|
>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
|
|
||||||
<!-- Champ de type BOOLEAN -->
|
<!-- Champ de type BOOLEAN -->
|
||||||
<div
|
<div
|
||||||
v-else-if="resolveFieldType(field) === 'boolean'"
|
v-else-if="field.type === 'boolean'"
|
||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -85,21 +85,21 @@
|
|||||||
|
|
||||||
<!-- Champ de type DATE -->
|
<!-- Champ de type DATE -->
|
||||||
<input
|
<input
|
||||||
v-else-if="resolveFieldType(field) === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
:value="field.value ?? ''"
|
:value="field.value ?? ''"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:required="resolveFieldRequired(field)"
|
:required="field.required"
|
||||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||||
@blur="onBlur(field)"
|
@blur="onBlur(field)"
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- Champ de type TEXTAREA -->
|
<!-- Champ de type TEXTAREA -->
|
||||||
<textarea
|
<textarea
|
||||||
v-else-if="resolveFieldType(field) === 'textarea'"
|
v-else-if="field.type === 'textarea'"
|
||||||
:value="field.value ?? ''"
|
:value="field.value ?? ''"
|
||||||
class="textarea textarea-bordered textarea-sm"
|
class="textarea textarea-bordered textarea-sm"
|
||||||
:required="resolveFieldRequired(field)"
|
:required="field.required"
|
||||||
@input="onInput(field, ($event.target as HTMLTextAreaElement).value)"
|
@input="onInput(field, ($event.target as HTMLTextAreaElement).value)"
|
||||||
@blur="onBlur(field)"
|
@blur="onBlur(field)"
|
||||||
/>
|
/>
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
:value="field.value ?? ''"
|
:value="field.value ?? ''"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered input-sm"
|
||||||
:required="resolveFieldRequired(field)"
|
:required="field.required"
|
||||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||||
@blur="onBlur(field)"
|
@blur="onBlur(field)"
|
||||||
>
|
>
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
<!-- Mode lecture seule -->
|
<!-- Mode lecture seule -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="input input-bordered input-sm bg-base-200">
|
<div class="input input-bordered input-sm bg-base-200">
|
||||||
{{ formatFieldDisplayValue(field) }}
|
{{ formatValueForDisplay(field) }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,18 +128,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import { fieldKey, formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/customFields'
|
||||||
resolveFieldKey,
|
|
||||||
resolveFieldName,
|
|
||||||
resolveFieldType,
|
|
||||||
resolveFieldOptions,
|
|
||||||
resolveFieldRequired,
|
|
||||||
resolveFieldReadOnly,
|
|
||||||
formatFieldDisplayValue,
|
|
||||||
} from '~/shared/utils/entityCustomFieldLogic'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
fields: any[]
|
fields: CustomFieldInput[]
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
columns?: 1 | 2
|
columns?: 1 | 2
|
||||||
title?: string
|
title?: string
|
||||||
@@ -150,8 +142,8 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'field-input': [field: any, value: string]
|
'field-input': [field: CustomFieldInput, value: string]
|
||||||
'field-blur': [field: any]
|
'field-blur': [field: CustomFieldInput]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const layoutClass = computed(() =>
|
const layoutClass = computed(() =>
|
||||||
@@ -170,16 +162,16 @@ const containerClass = computed(() =>
|
|||||||
const editable = computed(() => props.editable ?? true)
|
const editable = computed(() => props.editable ?? true)
|
||||||
const emitBlur = computed(() => props.emitBlur ?? true)
|
const emitBlur = computed(() => props.emitBlur ?? true)
|
||||||
|
|
||||||
function isFieldEditable(field: any) {
|
function isFieldEditable(field: CustomFieldInput) {
|
||||||
return props.isEditMode && editable.value && !resolveFieldReadOnly(field)
|
return props.isEditMode && editable.value && !field.readOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInput(field: any, value: string) {
|
function onInput(field: CustomFieldInput, value: string) {
|
||||||
field.value = value
|
field.value = value
|
||||||
emit('field-input', field, value)
|
emit('field-input', field, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBooleanChange(field: any, checked: boolean) {
|
function onBooleanChange(field: CustomFieldInput, checked: boolean) {
|
||||||
const value = checked ? 'true' : 'false'
|
const value = checked ? 'true' : 'false'
|
||||||
field.value = value
|
field.value = value
|
||||||
emit('field-input', field, value)
|
emit('field-input', field, value)
|
||||||
@@ -188,7 +180,7 @@ function onBooleanChange(field: any, checked: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBlur(field: any) {
|
function onBlur(field: CustomFieldInput) {
|
||||||
if (emitBlur.value) {
|
if (emitBlur.value) {
|
||||||
emit('field-blur', field)
|
emit('field-blur', field)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
|
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
fields: CustomFieldInput[]
|
fields: CustomFieldInput[]
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
>
|
>
|
||||||
<span class="loading loading-spinner text-primary" aria-hidden="true" />
|
<span class="loading loading-spinner text-primary" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<table :class="['table table-sm md:table-md', tableClass]">
|
<table :class="['table table-sm md:table-md', tableClass, { 'table-fixed': fixedLayout }]">
|
||||||
<thead>
|
<thead>
|
||||||
<!-- Header labels + sort -->
|
<!-- Header labels + sort -->
|
||||||
<tr>
|
<tr>
|
||||||
@@ -85,6 +85,7 @@
|
|||||||
alignClass(col),
|
alignClass(col),
|
||||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||||
]"
|
]"
|
||||||
|
:style="col.minWidth ? { minWidth: col.minWidth } : undefined"
|
||||||
>
|
>
|
||||||
<slot :name="`header-${col.key}`" :column="col">
|
<slot :name="`header-${col.key}`" :column="col">
|
||||||
<span
|
<span
|
||||||
@@ -221,6 +222,8 @@ const props = withDefaults(defineProps<{
|
|||||||
tableClass?: string
|
tableClass?: string
|
||||||
showCounter?: boolean
|
showCounter?: boolean
|
||||||
showPerPage?: boolean
|
showPerPage?: boolean
|
||||||
|
/** Use table-layout: fixed for stable column widths. Only enable on tables where columns define width/minWidth. */
|
||||||
|
fixedLayout?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
rowKey: 'id',
|
rowKey: 'id',
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
33
frontend/app/components/common/EmptyState.vue
Normal file
33
frontend/app/components/common/EmptyState.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div v-if="icon" class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
|
||||||
|
<component :is="icon" class="w-8 h-8 text-base-content/30" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-base-content mb-1">{{ title }}</h3>
|
||||||
|
<p v-if="description" class="text-sm text-base-content/50 mb-6">{{ description }}</p>
|
||||||
|
<slot>
|
||||||
|
<NuxtLink v-if="actionTo" :to="actionTo" class="btn btn-primary btn-sm">
|
||||||
|
{{ actionLabel }}
|
||||||
|
</NuxtLink>
|
||||||
|
<button v-else-if="actionLabel" type="button" class="btn btn-primary btn-sm" @click="$emit('action')">
|
||||||
|
{{ actionLabel }}
|
||||||
|
</button>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
icon?: Component
|
||||||
|
actionLabel?: string
|
||||||
|
actionTo?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
action: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
42
frontend/app/components/common/EntityTabs.vue
Normal file
42
frontend/app/components/common/EntityTabs.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<nav class="tabs tabs-bordered mb-6 overflow-x-auto flex-nowrap" role="tablist" :aria-label="ariaLabel">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
type="button"
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': modelValue === tab.key }"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="modelValue === tab.key"
|
||||||
|
@click="emit('update:modelValue', tab.key)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
<span v-if="tab.count !== undefined && tab.count > 0" class="badge badge-outline badge-xs ml-1.5">
|
||||||
|
{{ tab.count }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div role="tabpanel">
|
||||||
|
<slot :name="`tab-${modelValue}`" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface TabDefinition {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
tabs: TabDefinition[]
|
||||||
|
modelValue: string
|
||||||
|
ariaLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
49
frontend/app/components/common/UsedInSection.vue
Normal file
49
frontend/app/components/common/UsedInSection.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="!loading && totalCount > 0" class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4">
|
||||||
|
<h3 class="font-semibold text-base-content">Utilisé dans</h3>
|
||||||
|
|
||||||
|
<div v-if="data.machines.length" class="space-y-1">
|
||||||
|
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Machines</p>
|
||||||
|
<div v-for="m in data.machines" :key="m.id" class="flex items-center gap-2 text-sm">
|
||||||
|
<NuxtLink :to="`/machine/${m.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
|
||||||
|
{{ m.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-if="m.site?.name" class="badge badge-ghost badge-xs">{{ m.site.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="data.composants.length" class="space-y-1">
|
||||||
|
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Composants</p>
|
||||||
|
<div v-for="c in data.composants" :key="c.id" class="text-sm">
|
||||||
|
<NuxtLink :to="`/component/${c.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
|
||||||
|
{{ c.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="data.pieces.length" class="space-y-1">
|
||||||
|
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Pièces</p>
|
||||||
|
<div v-for="p in data.pieces" :key="p.id" class="text-sm">
|
||||||
|
<NuxtLink :to="`/piece/${p.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
|
||||||
|
{{ p.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="flex justify-center py-4">
|
||||||
|
<span class="loading loading-spinner loading-sm" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
entityType: 'composants' | 'pieces' | 'products'
|
||||||
|
entityId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { data, loading, totalCount } = useUsedIn(
|
||||||
|
computed(() => props.entityType),
|
||||||
|
computed(() => props.entityId),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
137
frontend/app/components/layout/AppBreadcrumb.vue
Normal file
137
frontend/app/components/layout/AppBreadcrumb.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<nav v-if="crumbs.length > 1" class="container mx-auto px-6 pt-4" aria-label="Fil d'Ariane">
|
||||||
|
<div class="text-sm breadcrumbs py-0">
|
||||||
|
<ul>
|
||||||
|
<!-- First crumb (always visible) -->
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="crumbs[0].path" class="text-base-content/60 hover:text-primary transition-colors">
|
||||||
|
{{ crumbs[0].label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<!-- Ellipsis on mobile when there are middle crumbs -->
|
||||||
|
<li v-if="crumbs.length > 2" class="sm:hidden">
|
||||||
|
<span class="text-base-content/40">…</span>
|
||||||
|
</li>
|
||||||
|
<!-- Middle crumbs: hidden on mobile, visible sm+ -->
|
||||||
|
<li
|
||||||
|
v-for="(crumb, i) in crumbs.slice(1, crumbs.length - 1)"
|
||||||
|
:key="i"
|
||||||
|
class="hidden sm:list-item"
|
||||||
|
>
|
||||||
|
<NuxtLink :to="crumb.path" class="text-base-content/60 hover:text-primary transition-colors">
|
||||||
|
{{ crumb.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<!-- Last crumb (always visible, current page) -->
|
||||||
|
<li v-if="crumbs.length > 1">
|
||||||
|
<span class="text-base-content font-medium">{{ crumbs[crumbs.length - 1].label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Crumb {
|
||||||
|
label: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const crumbs = computed<Crumb[]>(() => {
|
||||||
|
const result: Crumb[] = [{ label: 'Accueil', path: '/' }]
|
||||||
|
const path = route.path
|
||||||
|
|
||||||
|
// Home page — no breadcrumb
|
||||||
|
if (path === '/') return []
|
||||||
|
|
||||||
|
// Machine context from query param (when navigating from a machine detail page)
|
||||||
|
if (route.query.from === 'machine' && route.query.machineId) {
|
||||||
|
result.push({ label: 'Parc machines', path: '/machines' })
|
||||||
|
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Machines
|
||||||
|
if (path === '/machines') {
|
||||||
|
result.push({ label: 'Parc machines', path: '/machines' })
|
||||||
|
} else if (path.startsWith('/machine/') && !route.query.from) {
|
||||||
|
result.push({ label: 'Parc machines', path: '/machines' })
|
||||||
|
result.push({ label: 'Machine', path })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalogs
|
||||||
|
else if (path.startsWith('/catalogues/composants')) {
|
||||||
|
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||||
|
} else if (path.startsWith('/catalogues/pieces')) {
|
||||||
|
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||||
|
} else if (path.startsWith('/catalogues/produits')) {
|
||||||
|
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity detail pages (when NOT from machine context)
|
||||||
|
else if (path.startsWith('/component/') && !route.query.from) {
|
||||||
|
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||||
|
result.push({ label: 'Composant', path })
|
||||||
|
} else if (path.startsWith('/piece/') && !route.query.from) {
|
||||||
|
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||||
|
result.push({ label: 'Pièce', path })
|
||||||
|
} else if (path.startsWith('/product/') && !route.query.from) {
|
||||||
|
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||||
|
result.push({ label: 'Produit', path })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity detail pages WITH machine context — add entity as last crumb
|
||||||
|
else if (path.startsWith('/component/') && route.query.from === 'machine') {
|
||||||
|
result.push({ label: 'Composant', path })
|
||||||
|
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
|
||||||
|
result.push({ label: 'Pièce', path })
|
||||||
|
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
|
||||||
|
result.push({ label: 'Produit', path })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin pages
|
||||||
|
else if (path.startsWith('/sites')) {
|
||||||
|
result.push({ label: 'Sites', path: '/sites' })
|
||||||
|
} else if (path.startsWith('/constructeurs')) {
|
||||||
|
result.push({ label: 'Fournisseurs', path: '/constructeurs' })
|
||||||
|
} else if (path.startsWith('/activity-log')) {
|
||||||
|
result.push({ label: 'Journal d\'activité', path: '/activity-log' })
|
||||||
|
} else if (path.startsWith('/admin')) {
|
||||||
|
result.push({ label: 'Administration', path: '/admin' })
|
||||||
|
} else if (path.startsWith('/documents')) {
|
||||||
|
result.push({ label: 'Documents', path: '/documents' })
|
||||||
|
} else if (path.startsWith('/comments')) {
|
||||||
|
result.push({ label: 'Commentaires', path: '/comments' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category pages
|
||||||
|
else if (path.startsWith('/component-category')) {
|
||||||
|
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||||
|
result.push({ label: 'Catégorie', path })
|
||||||
|
} else if (path.startsWith('/piece-category')) {
|
||||||
|
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||||
|
result.push({ label: 'Catégorie', path })
|
||||||
|
} else if (path.startsWith('/product-category')) {
|
||||||
|
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||||
|
result.push({ label: 'Catégorie', path })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pages
|
||||||
|
else if (path.startsWith('/pieces/create')) {
|
||||||
|
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||||
|
result.push({ label: 'Nouvelle pièce', path })
|
||||||
|
} else if (path.startsWith('/component/create')) {
|
||||||
|
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||||
|
result.push({ label: 'Nouveau composant', path })
|
||||||
|
} else if (path.startsWith('/product/create')) {
|
||||||
|
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||||
|
result.push({ label: 'Nouveau produit', path })
|
||||||
|
} else if (path === '/machines/new') {
|
||||||
|
result.push({ label: 'Parc machines', path: '/machines' })
|
||||||
|
result.push({ label: 'Nouvelle machine', path })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
<!-- Mobile: dropdown groups -->
|
<!-- Mobile: dropdown groups -->
|
||||||
<li
|
<li
|
||||||
v-for="group in navGroups"
|
v-for="group in visibleGroups"
|
||||||
:key="group.id + '-mobile'"
|
:key="group.id + '-mobile'"
|
||||||
class="mt-1 border-t border-base-200 pt-2"
|
class="mt-1 border-t border-base-200 pt-2"
|
||||||
>
|
>
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
|
|
||||||
<!-- Desktop: dropdown groups -->
|
<!-- Desktop: dropdown groups -->
|
||||||
<li
|
<li
|
||||||
v-for="group in navGroups"
|
v-for="group in visibleGroups"
|
||||||
:key="group.id + '-desktop'"
|
:key="group.id + '-desktop'"
|
||||||
class="relative"
|
class="relative"
|
||||||
@mouseenter="setDropdown(group.id + '-desktop')"
|
@mouseenter="setDropdown(group.id + '-desktop')"
|
||||||
@@ -270,11 +270,9 @@ import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
|||||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||||
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||||
import IconLucideFactory from '~icons/lucide/factory'
|
import IconLucideFactory from '~icons/lucide/factory'
|
||||||
|
import IconLucideBookOpen from '~icons/lucide/book-open'
|
||||||
|
|
||||||
import IconLucideCpu from '~icons/lucide/cpu'
|
|
||||||
import IconLucidePuzzle from '~icons/lucide/puzzle'
|
|
||||||
import IconLucidePackage from '~icons/lucide/package'
|
import IconLucidePackage from '~icons/lucide/package'
|
||||||
import IconLucideLink from '~icons/lucide/link'
|
|
||||||
import IconLucideSun from '~icons/lucide/sun'
|
import IconLucideSun from '~icons/lucide/sun'
|
||||||
import IconLucideMoon from '~icons/lucide/moon'
|
import IconLucideMoon from '~icons/lucide/moon'
|
||||||
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||||
@@ -296,55 +294,40 @@ interface NavGroup {
|
|||||||
icon?: Component
|
icon?: Component
|
||||||
activePaths: string[]
|
activePaths: string[]
|
||||||
children: NavLink[]
|
children: NavLink[]
|
||||||
|
requiresEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const simpleLinks: NavLink[] = [
|
const simpleLinks: NavLink[] = [
|
||||||
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
||||||
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
||||||
|
{ to: '/doc', label: 'Documentation', icon: IconLucideBookOpen },
|
||||||
]
|
]
|
||||||
|
|
||||||
const navGroups: NavGroup[] = [
|
const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
id: 'component',
|
id: 'catalogues',
|
||||||
label: 'Composants',
|
label: 'Catalogues',
|
||||||
icon: IconLucideCpu,
|
|
||||||
activePaths: ['/component-category', '/component-catalog'],
|
|
||||||
children: [
|
|
||||||
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
|
||||||
{ to: '/component-category', label: 'Catégorie de composant' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pieces',
|
|
||||||
label: 'Pièces',
|
|
||||||
icon: IconLucidePuzzle,
|
|
||||||
activePaths: ['/piece-category', '/pieces-catalog'],
|
|
||||||
children: [
|
|
||||||
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
|
||||||
{ to: '/piece-category', label: 'Catégorie de pièce' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'products',
|
|
||||||
label: 'Produits',
|
|
||||||
icon: IconLucidePackage,
|
icon: IconLucidePackage,
|
||||||
activePaths: ['/product-category', '/product-catalog'],
|
activePaths: ['/catalogues', '/component', '/piece', '/product'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
{ to: '/catalogues/composants', label: 'Composants' },
|
||||||
{ to: '/product-category', label: 'Catégorie de produit' },
|
{ to: '/catalogues/pieces', label: 'Pièces' },
|
||||||
|
{ to: '/catalogues/produits', label: 'Produits' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'resources',
|
id: 'admin',
|
||||||
label: 'Ressources liées',
|
label: 'Administration',
|
||||||
icon: IconLucideLink,
|
icon: IconLucideSettings,
|
||||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
activePaths: ['/sites', '/constructeurs', '/activity-log', '/admin', '/documents', '/comments', '/component-category', '/piece-category', '/product-category'],
|
||||||
|
requiresEdit: true,
|
||||||
children: [
|
children: [
|
||||||
{ to: '/sites', label: 'Sites' },
|
{ to: '/sites', label: 'Sites' },
|
||||||
{ to: '/documents', label: 'Documents' },
|
{ to: '/documents', label: 'Documents' },
|
||||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||||
{ to: '/comments', label: 'Commentaires' },
|
{ to: '/comments', label: 'Commentaires' },
|
||||||
{ to: '/activity-log', label: 'Journal d\'activité' },
|
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||||
|
{ to: '/admin', label: 'Profils' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -353,6 +336,10 @@ const route = useRoute()
|
|||||||
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
||||||
const { activeProfile } = useProfileSession()
|
const { activeProfile } = useProfileSession()
|
||||||
const { isAdmin, canEdit } = usePermissions()
|
const { isAdmin, canEdit } = usePermissions()
|
||||||
|
|
||||||
|
const visibleGroups = computed(() =>
|
||||||
|
navGroups.filter(g => !g.requiresEdit || canEdit.value)
|
||||||
|
)
|
||||||
const { fetchUnresolvedCount } = useComments()
|
const { fetchUnresolvedCount } = useComments()
|
||||||
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
|
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
<div class="card bg-base-100 shadow-sm">
|
<div class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="card-title">Composants</h2>
|
<h2 class="card-title">
|
||||||
|
Composants
|
||||||
|
<span v-if="components.length" class="badge badge-outline badge-sm ml-1">{{ components.length }}</span>
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-sm gap-2"
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<span class="label-text text-sm">{{ field.name }}</span>
|
<span class="label-text text-sm">{{ field.name }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="input input-bordered input-sm bg-base-200">
|
<div class="input input-bordered input-sm bg-base-200">
|
||||||
{{ formatCustomFieldValue(field) }}
|
{{ formatValueForDisplay(field) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import IconLucideTrash from '~icons/lucide/trash'
|
import IconLucideTrash from '~icons/lucide/trash'
|
||||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
import { formatValueForDisplay } from '~/shared/utils/customFields'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
customFields: any[]
|
customFields: any[]
|
||||||
|
|||||||
@@ -1,40 +1,46 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
<div class="space-y-3">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
<h1 class="text-3xl font-bold">
|
<div class="flex flex-col gap-1">
|
||||||
{{ title }}
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
</h1>
|
<h1 class="text-2xl font-bold">{{ title }}</h1>
|
||||||
</div>
|
<div
|
||||||
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
v-if="siteName"
|
||||||
<button
|
class="badge badge-outline font-semibold"
|
||||||
@click="$emit('toggle-edit')"
|
:style="siteStyle"
|
||||||
class="btn btn-primary"
|
>
|
||||||
:class="{ 'btn-outline': isEditMode }"
|
{{ siteName }}
|
||||||
>
|
</div>
|
||||||
<IconLucideSquarePen
|
<div v-if="reference" class="badge badge-outline">{{ reference }}</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="description" class="text-sm text-base-content/60">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 print:hidden">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm md:btn-md"
|
||||||
|
:class="{ 'btn-outline': isEditMode }"
|
||||||
|
@click="$emit('toggle-edit')"
|
||||||
|
>
|
||||||
|
<IconLucideSquarePen v-if="!isEditMode" class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
<IconLucideEye v-else class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
{{ isEditMode ? 'Voir d\u00e9tails' : 'Modifier' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
v-if="!isEditMode"
|
v-if="!isEditMode"
|
||||||
class="w-5 h-5 mr-2"
|
type="button"
|
||||||
aria-hidden="true"
|
class="btn btn-ghost btn-sm md:btn-md"
|
||||||
/>
|
title="Imprimer"
|
||||||
<IconLucideEye
|
@click="$emit('open-print')"
|
||||||
v-else
|
>
|
||||||
class="w-5 h-5 mr-2"
|
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
|
||||||
aria-hidden="true"
|
</button>
|
||||||
/>
|
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
|
||||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
</button>
|
Parc machines
|
||||||
<button
|
</NuxtLink>
|
||||||
v-if="!isEditMode"
|
</div>
|
||||||
@click="$emit('open-print')"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-outline btn-secondary"
|
|
||||||
>
|
|
||||||
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
|
|
||||||
Imprimer
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
|
||||||
Retour aux machines
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -43,11 +49,16 @@
|
|||||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||||
import IconLucideEye from '~icons/lucide/eye'
|
import IconLucideEye from '~icons/lucide/eye'
|
||||||
import IconLucidePrinter from '~icons/lucide/printer'
|
import IconLucidePrinter from '~icons/lucide/printer'
|
||||||
|
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
||||||
|
|
||||||
const router = useRouter()
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
title: string
|
title: string
|
||||||
|
description?: string
|
||||||
|
siteName?: string
|
||||||
|
siteColor?: string
|
||||||
|
reference?: string
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -56,12 +67,12 @@ defineEmits<{
|
|||||||
'open-print': []
|
'open-print': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function goBack() {
|
const siteStyle = computed(() => {
|
||||||
if (window.history.length > 1) {
|
if (!props.siteColor) return {}
|
||||||
router.back()
|
return {
|
||||||
|
borderColor: props.siteColor + '60',
|
||||||
|
backgroundColor: props.siteColor + '25',
|
||||||
|
color: props.siteColor,
|
||||||
}
|
}
|
||||||
else {
|
})
|
||||||
navigateTo('/machines')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title">Documents de la machine</h2>
|
<h2 class="card-title">
|
||||||
|
Documents de la machine
|
||||||
|
<span v-if="documents.length" class="badge badge-outline badge-sm ml-1">{{ documents.length }}</span>
|
||||||
|
</h2>
|
||||||
<p class="text-xs text-gray-500">Ajoutez ou consultez les documents liés à cette machine.</p>
|
<p class="text-xs text-gray-500">Ajoutez ou consultez les documents liés à cette machine.</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="isEditMode && files.length" class="badge badge-outline">
|
<span v-if="isEditMode && files.length" class="badge badge-outline">
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
|
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
|
||||||
/>
|
/>
|
||||||
<div v-else class="input input-bordered bg-base-200">
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
{{ machineName }}
|
{{ machineName }}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -38,9 +38,9 @@
|
|||||||
{{ site.name }}
|
{{ site.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else class="input input-bordered bg-base-200">
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
{{ machineSiteName || 'Non défini' }}
|
{{ machineSiteName || 'Non défini' }}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isEditMode || machineReference" class="form-control">
|
<div v-if="isEditMode || machineReference" class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -54,9 +54,9 @@
|
|||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
|
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
|
||||||
/>
|
/>
|
||||||
<div v-else class="input input-bordered bg-base-200">
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
{{ machineReference }}
|
{{ machineReference }}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
|
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -77,9 +77,9 @@
|
|||||||
@update:model-value="$emit('update:constructeur-links', $event)"
|
@update:model-value="$emit('update:constructeur-links', $event)"
|
||||||
@remove="$emit('remove-constructeur-link', $event)"
|
@remove="$emit('remove-constructeur-link', $event)"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="!isEditMode" class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
|
<p v-else-if="!isEditMode" class="text-sm font-medium text-base-content/50 py-1">
|
||||||
<span class="text-base-content/50">Non défini</span>
|
Non défini
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,9 +152,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="input input-bordered input-sm bg-base-200">
|
<p class="text-sm font-medium text-base-content py-1">
|
||||||
{{ formatCustomFieldValue(field) }}
|
{{ formatValueForDisplay(field) }}
|
||||||
</div>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +182,7 @@ import { watch } from 'vue'
|
|||||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
|
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
|
||||||
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
|
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
|
||||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
import { formatValueForDisplay } from '~/shared/utils/customFields'
|
||||||
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
|
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
|
||||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
<div class="card bg-base-100 shadow-sm">
|
<div class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="card-title">Pièces de la machine</h2>
|
<h2 class="card-title">
|
||||||
|
Pièces de la machine
|
||||||
|
<span v-if="pieces.length" class="badge badge-outline badge-sm ml-1">{{ pieces.length }}</span>
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-sm gap-2"
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
|||||||
@@ -29,7 +29,16 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
<p class="font-semibold" :class="product.pendingEntity ? 'text-error' : 'text-base-content'">
|
<p class="font-semibold" :class="product.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||||
{{ product.name }}
|
<NuxtLink
|
||||||
|
v-if="!isEditMode && !product.pendingEntity && product.id"
|
||||||
|
:to="machineId
|
||||||
|
? { path: `/product/${product.id}`, query: { from: 'machine', machineId } }
|
||||||
|
: `/product/${product.id}`"
|
||||||
|
class="hover:underline hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{{ product.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ product.name }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -133,7 +142,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import {
|
import {
|
||||||
@@ -142,6 +151,9 @@ import {
|
|||||||
downloadDocument,
|
downloadDocument,
|
||||||
} from '~/shared/utils/documentDisplayUtils'
|
} from '~/shared/utils/documentDisplayUtils'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const machineId = computed(() => route.params.id as string | undefined)
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
products: Array<{
|
products: Array<{
|
||||||
id?: string | null
|
id?: string | null
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
<main
|
<main
|
||||||
class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8"
|
class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8"
|
||||||
>
|
>
|
||||||
<header class="space-y-2">
|
<template v-if="!hideHeading">
|
||||||
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
|
<header class="space-y-2">
|
||||||
<p class="text-base text-base-content/70">
|
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
|
||||||
{{ descriptionText }}
|
<p class="text-base text-base-content/70">
|
||||||
</p>
|
{{ descriptionText }}
|
||||||
</header>
|
</p>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
v-if="allowCategorySwitch"
|
v-if="allowCategorySwitch"
|
||||||
@@ -55,16 +57,6 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="openCreatePage"
|
|
||||||
>
|
|
||||||
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
|
|
||||||
Créer
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-name="{ row }">
|
<template #cell-name="{ row }">
|
||||||
@@ -76,19 +68,15 @@
|
|||||||
<span v-else class="text-base-content/50">—</span>
|
<span v-else class="text-base-content/50">—</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-createdAt="{ row }">
|
||||||
|
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="canEdit && showConvertButton"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs text-warning"
|
|
||||||
@click="openConversionModal(row)"
|
|
||||||
>
|
|
||||||
Convertir
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
@@ -99,13 +87,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<ConversionModal
|
|
||||||
:open="conversionModalOpen"
|
|
||||||
:model-type="conversionTarget"
|
|
||||||
@close="closeConversionModal"
|
|
||||||
@converted="onConverted"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RelatedItemsModal
|
<RelatedItemsModal
|
||||||
:open="relatedModalOpen"
|
:open="relatedModalOpen"
|
||||||
:model-type="relatedType"
|
:model-type="relatedType"
|
||||||
@@ -119,7 +100,6 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||||
import { useHead, useRouter } from '#imports'
|
import { useHead, useRouter } from '#imports'
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import ConversionModal from '~/components/model-types/ConversionModal.vue'
|
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import type { DataTableSort } from '~/shared/types/dataTable'
|
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||||
import {
|
import {
|
||||||
@@ -133,7 +113,7 @@ import { useToast } from '~/composables/useToast'
|
|||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import IconLucideSearch from '~icons/lucide/search'
|
import IconLucideSearch from '~icons/lucide/search'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
|
|
||||||
const DEFAULT_DESCRIPTION
|
const DEFAULT_DESCRIPTION
|
||||||
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
||||||
@@ -144,9 +124,11 @@ const props = withDefaults(
|
|||||||
heading: string
|
heading: string
|
||||||
description?: string
|
description?: string
|
||||||
allowCategorySwitch?: boolean
|
allowCategorySwitch?: boolean
|
||||||
|
hideHeading?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
allowCategorySwitch: false,
|
allowCategorySwitch: false,
|
||||||
|
hideHeading: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -195,12 +177,11 @@ useHead(() => ({ title: headingText.value }))
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: 'Nom', sortable: true },
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
{ key: 'notes', label: 'Notes' },
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||||
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const showConvertButton = computed(() =>
|
const formatDate = formatFrenchDate
|
||||||
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
|
|
||||||
)
|
|
||||||
|
|
||||||
const categories: Array<{ label: string, value: ModelCategory }> = [
|
const categories: Array<{ label: string, value: ModelCategory }> = [
|
||||||
{ label: 'Composants', value: 'COMPONENT' },
|
{ label: 'Composants', value: 'COMPONENT' },
|
||||||
@@ -335,13 +316,6 @@ const resolveCategoryBasePath = (category: ModelCategory) => {
|
|||||||
return '/product-category'
|
return '/product-category'
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreatePage = () => {
|
|
||||||
const basePath = resolveCategoryBasePath(selectedCategory.value)
|
|
||||||
router.push(`${basePath}/new`).catch(() => {
|
|
||||||
showError('Navigation impossible vers la page de création.')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditPage = (item: ModelType) => {
|
const openEditPage = (item: ModelType) => {
|
||||||
const category = item.category ?? selectedCategory.value
|
const category = item.category ?? selectedCategory.value
|
||||||
const basePath = resolveCategoryBasePath(category)
|
const basePath = resolveCategoryBasePath(category)
|
||||||
@@ -396,26 +370,6 @@ const openRelatedEdit = (entry: { id: string }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversionModalOpen = ref(false)
|
|
||||||
const conversionTarget = ref<ModelType | null>(null)
|
|
||||||
|
|
||||||
const openConversionModal = (item: ModelType) => {
|
|
||||||
conversionTarget.value = item
|
|
||||||
conversionModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeConversionModal = () => {
|
|
||||||
conversionModalOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onConverted = () => {
|
|
||||||
conversionModalOpen.value = false
|
|
||||||
invalidateEntityTypeCache('PIECE')
|
|
||||||
invalidateEntityTypeCache('COMPONENT')
|
|
||||||
showSuccess('Catégorie convertie avec succès.')
|
|
||||||
doRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
@@ -99,11 +99,7 @@
|
|||||||
v-else
|
v-else
|
||||||
class="space-y-3 rounded-lg border border-base-300 p-4"
|
class="space-y-3 rounded-lg border border-base-300 p-4"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-base-content/70">
|
<PieceModelStructureEditor v-model="productStructure" hide-products />
|
||||||
Aperçu :
|
|
||||||
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
|
||||||
</p>
|
|
||||||
<PieceModelStructureEditor v-model="productStructure" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@@ -194,15 +190,16 @@ const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const formulaBuilderCustomFields = computed(() => {
|
const formulaBuilderCustomFields = computed(() => {
|
||||||
|
let fields: any[] = []
|
||||||
if (form.category === 'PIECE') {
|
if (form.category === 'PIECE') {
|
||||||
const fields = pieceStructure.value?.customFields
|
const raw = pieceStructure.value?.customFields
|
||||||
return Array.isArray(fields) ? fields : []
|
fields = Array.isArray(raw) ? raw : []
|
||||||
}
|
}
|
||||||
if (form.category === 'COMPONENT') {
|
else if (form.category === 'COMPONENT') {
|
||||||
const fields = componentStructure.value?.customFields
|
const raw = componentStructure.value?.customFields
|
||||||
return Array.isArray(fields) ? fields : []
|
fields = Array.isArray(raw) ? raw : []
|
||||||
}
|
}
|
||||||
return []
|
return fields.filter((f: any) => !f.machineContextOnly)
|
||||||
})
|
})
|
||||||
|
|
||||||
const extractFormulaFields = (formula: string | null | undefined): string[] => {
|
const extractFormulaFields = (formula: string | null | undefined): string[] => {
|
||||||
|
|||||||
@@ -31,16 +31,28 @@
|
|||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
class="px-2 py-1"
|
class="px-2 py-1"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
type="button"
|
class="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-2 hover:bg-base-200"
|
||||||
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
|
||||||
@click="onOpenEdit(entry)"
|
|
||||||
>
|
>
|
||||||
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
<div class="flex min-w-0 flex-col gap-0.5">
|
||||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
<NuxtLink
|
||||||
Référence: {{ entry.reference }}
|
:to="itemDetailPath(entry)"
|
||||||
</span>
|
class="font-medium hover:underline hover:text-primary transition-colors"
|
||||||
</button>
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
{{ entry.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||||
|
Référence: {{ entry.reference }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0">
|
||||||
|
<span v-if="entry.machineCount > 0" class="badge badge-ghost badge-sm">
|
||||||
|
{{ entry.machineCount }} machine{{ entry.machineCount > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-xs text-base-content/30">Aucune machine</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,14 +69,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
|
||||||
import type { ModelCategory, ModelType } from '~/services/modelTypes'
|
import type { ModelCategory, ModelType } from '~/services/modelTypes'
|
||||||
|
|
||||||
type RelatedEntry = {
|
type RelatedEntry = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
reference?: string | null
|
reference?: string | null
|
||||||
|
machineCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -104,73 +115,37 @@ const modalSubtitle = computed(() => {
|
|||||||
return `${count} ${labels.plural} liés.`
|
return `${count} ${labels.plural} liés.`
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
const itemDetailPath = (item: RelatedEntry) => {
|
||||||
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
|
if (!props.modelType) return '#'
|
||||||
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
|
const category = props.modelType.category
|
||||||
return { endpoint: '/products', filterKey: 'typeProduct' }
|
if (category === 'COMPONENT') return `/component/${item.id}`
|
||||||
}
|
if (category === 'PIECE') return `/piece/${item.id}`
|
||||||
|
return `/product/${item.id}`
|
||||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
|
||||||
if (!item || typeof item !== 'object') return null
|
|
||||||
const record = item as Record<string, unknown>
|
|
||||||
if (typeof record.id !== 'string') return null
|
|
||||||
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
|
|
||||||
const reference
|
|
||||||
= typeof record.reference === 'string' && record.reference.trim()
|
|
||||||
? record.reference
|
|
||||||
: typeof record.code === 'string' && record.code.trim()
|
|
||||||
? record.code
|
|
||||||
: null
|
|
||||||
return { id: record.id, name, reference }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadRelatedItems = async (modelType: ModelType) => {
|
const loadRelatedItems = async (modelType: ModelType) => {
|
||||||
const { endpoint, filterKey } = resolveRelatedConfig(modelType.category)
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.set('itemsPerPage', '200')
|
|
||||||
params.set(filterKey, `/api/model_types/${modelType.id}`)
|
|
||||||
params.set('order[name]', 'asc')
|
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
items.value = []
|
items.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await get(`${endpoint}?${params.toString()}`)
|
const result = await get(`/model_types/${modelType.id}/related-items`)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
error.value = result.error ?? 'Impossible de charger les éléments liés.'
|
error.value = result.error ?? 'Impossible de charger les éléments liés.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const collection = extractCollection(result.data)
|
if (Array.isArray(result.data)) {
|
||||||
items.value = collection
|
items.value = result.data as RelatedEntry[]
|
||||||
.map(mapRelatedEntry)
|
|
||||||
.filter((entry): entry is RelatedEntry => Boolean(entry))
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
let raw: string | null = null
|
|
||||||
if (err && typeof err === 'object') {
|
|
||||||
const e = err as { data?: Record<string, unknown>, statusMessage?: string, message?: string }
|
|
||||||
if (e.data) {
|
|
||||||
const data = e.data
|
|
||||||
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
|
|
||||||
else if (typeof data.detail === 'string') raw = data.detail
|
|
||||||
else if (typeof data.message === 'string') raw = data.message
|
|
||||||
else if (typeof data.error === 'string') raw = data.error
|
|
||||||
}
|
|
||||||
if (!raw && typeof e.statusMessage === 'string') raw = e.statusMessage
|
|
||||||
if (!raw && typeof e.message === 'string') raw = e.message
|
|
||||||
}
|
}
|
||||||
error.value = humanizeError(raw)
|
}
|
||||||
|
catch {
|
||||||
|
error.value = 'Impossible de charger les éléments liés.'
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOpenEdit = (entry: RelatedEntry) => {
|
|
||||||
emit('open-edit', entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
(isOpen) => {
|
(isOpen) => {
|
||||||
|
|||||||
@@ -11,13 +11,14 @@
|
|||||||
<h3 class="card-title text-lg text-base-content">
|
<h3 class="card-title text-lg text-base-content">
|
||||||
{{ site.name }}
|
{{ site.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<div
|
<NuxtLink
|
||||||
class="badge font-bold"
|
:to="`/machines?sites=${site.id}`"
|
||||||
|
class="badge font-bold hover:opacity-80 transition-opacity"
|
||||||
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
|
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
|
||||||
:class="!site.color ? 'badge-primary' : ''"
|
:class="!site.color ? 'badge-primary' : ''"
|
||||||
>
|
>
|
||||||
{{ machineCount }} machines
|
{{ machineCount }} machines
|
||||||
</div>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 text-sm">
|
<div class="space-y-3 text-sm">
|
||||||
@@ -39,10 +40,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-base-content/60">
|
<NuxtLink :to="`/machines?sites=${site.id}`" class="flex items-center gap-2 text-base-content/60 hover:text-primary transition-colors">
|
||||||
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
||||||
<span>{{ machineCount }} machine(s)</span>
|
<span>{{ machineCount }} machine(s)</span>
|
||||||
</div>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
|
|||||||
@@ -17,15 +17,9 @@ import { useProductTypes } from '~/composables/useProductTypes'
|
|||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
import {
|
import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
|
||||||
type CustomFieldInput,
|
|
||||||
normalizeCustomFieldInputs,
|
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
|
||||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||||
@@ -40,7 +34,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
hasAssignments,
|
hasAssignments,
|
||||||
initializeStructureAssignments,
|
initializeStructureAssignments,
|
||||||
isAssignmentNodeComplete,
|
|
||||||
serializeStructureAssignments,
|
serializeStructureAssignments,
|
||||||
} from '~/shared/utils/structureAssignmentHelpers'
|
} from '~/shared/utils/structureAssignmentHelpers'
|
||||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
@@ -77,7 +70,6 @@ export function useComponentCreate() {
|
|||||||
loading: productsLoading,
|
loading: productsLoading,
|
||||||
} = useProducts()
|
} = useProducts()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
const { syncLinks } = useConstructeurLinks()
|
const { syncLinks } = useConstructeurLinks()
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
@@ -98,7 +90,8 @@ export function useComponentCreate() {
|
|||||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||||
const lastSuggestedName = ref('')
|
const lastSuggestedName = ref('')
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const createdComponentId = ref<string | null>(null)
|
||||||
|
|
||||||
const structureAssignments = ref<StructureAssignmentNode | null>(null)
|
const structureAssignments = ref<StructureAssignmentNode | null>(null)
|
||||||
const selectedDocuments = ref<File[]>([])
|
const selectedDocuments = ref<File[]>([])
|
||||||
const uploadingDocuments = ref(false)
|
const uploadingDocuments = ref(false)
|
||||||
@@ -148,26 +141,24 @@ export function useComponentCreate() {
|
|||||||
return structure ? normalizeStructureForEditor(structure) : null
|
return structure ? normalizeStructureForEditor(structure) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
fields: customFieldInputs,
|
||||||
|
requiredFilled: requiredCustomFieldsFilled,
|
||||||
|
saveAll: saveAllCustomFields,
|
||||||
|
refresh: refreshCustomFieldInputs,
|
||||||
|
} = useCustomFieldInputs({
|
||||||
|
definitions: computed(() => selectedTypeStructure.value?.customFields ?? []),
|
||||||
|
values: computed(() => []),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: createdComponentId,
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
const structureHasRequirements = computed(() =>
|
const structureHasRequirements = computed(() =>
|
||||||
hasAssignments(structureAssignments.value),
|
hasAssignments(structureAssignments.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
const structureSelectionsComplete = computed(() => {
|
const structureSelectionsComplete = computed(() => true)
|
||||||
if (!structureHasRequirements.value) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (structureDataLoading.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!structureAssignments.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return isAssignmentNodeComplete(structureAssignments.value, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
|
||||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
canEdit.value
|
canEdit.value
|
||||||
@@ -225,7 +216,6 @@ export function useComponentCreate() {
|
|||||||
watch(selectedType, (type) => {
|
watch(selectedType, (type) => {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
clearCreationForm()
|
clearCreationForm()
|
||||||
customFieldInputs.value = []
|
|
||||||
structureAssignments.value = null
|
structureAssignments.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -233,7 +223,8 @@ export function useComponentCreate() {
|
|||||||
creationForm.name = type.name
|
creationForm.name = type.name
|
||||||
}
|
}
|
||||||
lastSuggestedName.value = creationForm.name
|
lastSuggestedName.value = creationForm.name
|
||||||
customFieldInputs.value = normalizeCustomFieldInputs(selectedTypeStructure.value)
|
// useCustomFieldInputs auto-refreshes via its watcher on definitions
|
||||||
|
refreshCustomFieldInputs()
|
||||||
structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value)
|
structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -305,11 +296,6 @@ export function useComponentCreate() {
|
|||||||
payload.productId = rootProductSelection.selectedProductId.trim()
|
payload.productId = rootProductSelection.selectedProductId.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
|
|
||||||
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializedStructure = structureHasRequirements.value
|
const serializedStructure = structureHasRequirements.value
|
||||||
? serializeStructureAssignments(structureAssignments.value)
|
? serializeStructureAssignments(structureAssignments.value)
|
||||||
: null
|
: null
|
||||||
@@ -323,12 +309,11 @@ export function useComponentCreate() {
|
|||||||
const result = await createComposant(payload)
|
const result = await createComposant(payload)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const createdComponent = result.data as Record<string, any>
|
const createdComponent = result.data as Record<string, any>
|
||||||
await _saveCustomFieldValues(
|
createdComponentId.value = createdComponent.id
|
||||||
'composant',
|
const failedFields = await saveAllCustomFields()
|
||||||
createdComponent.id,
|
if (failedFields.length) {
|
||||||
[createdComponent?.typeComposant?.structure?.customFields],
|
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
}
|
||||||
)
|
|
||||||
if (selectedDocuments.value.length && result.data?.id) {
|
if (selectedDocuments.value.length && result.data?.id) {
|
||||||
uploadingDocuments.value = true
|
uploadingDocuments.value = true
|
||||||
const uploadResult = await uploadDocuments(
|
const uploadResult = await uploadDocuments(
|
||||||
@@ -413,6 +398,7 @@ export function useComponentCreate() {
|
|||||||
structureSelectionsComplete,
|
structureSelectionsComplete,
|
||||||
canEdit,
|
canEdit,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
typeOptionLabel,
|
typeOptionLabel,
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ import { usePieceTypes } from '~/composables/usePieceTypes'
|
|||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||||
import { useComponentHistory } from '~/composables/useComponentHistory'
|
import { useEntityHistory } from '~/composables/useEntityHistory'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||||
@@ -29,12 +28,7 @@ import {
|
|||||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
import type { ModelType } from '~/services/modelTypes'
|
import type { ModelType } from '~/services/modelTypes'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import {
|
import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
|
||||||
type CustomFieldInput,
|
|
||||||
buildCustomFieldInputs,
|
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
|
||||||
import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils'
|
import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils'
|
||||||
|
|
||||||
interface ComponentCatalogType extends ModelType {
|
interface ComponentCatalogType extends ModelType {
|
||||||
@@ -64,7 +58,6 @@ export function useComponentEdit(componentId: string) {
|
|||||||
const { products } = useProducts()
|
const { products } = useProducts()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
const {
|
const {
|
||||||
@@ -72,7 +65,7 @@ export function useComponentEdit(componentId: string) {
|
|||||||
loading: historyLoading,
|
loading: historyLoading,
|
||||||
error: historyError,
|
error: historyError,
|
||||||
loadHistory,
|
loadHistory,
|
||||||
} = useComponentHistory()
|
} = useEntityHistory('composant')
|
||||||
|
|
||||||
const component = ref<any | null>(null)
|
const component = ref<any | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -96,7 +89,6 @@ export function useComponentEdit(componentId: string) {
|
|||||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
|
||||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||||
const pieceTypeLabelMap = computed(() =>
|
const pieceTypeLabelMap = computed(() =>
|
||||||
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||||
@@ -207,18 +199,23 @@ export function useComponentEdit(componentId: string) {
|
|||||||
return structure ? normalizeStructureForEditor(structure) : null
|
return structure ? normalizeStructureForEditor(structure) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshCustomFieldInputs = (
|
const {
|
||||||
structureOverride?: ComponentModelStructure | null,
|
fields: customFieldInputs,
|
||||||
valuesOverride?: any[] | null,
|
requiredFilled: requiredCustomFieldsFilled,
|
||||||
) => {
|
saveAll: saveAllCustomFields,
|
||||||
const structure = structureOverride ?? selectedTypeStructure.value ?? null
|
refresh: refreshCustomFieldInputs,
|
||||||
const values = valuesOverride ?? component.value?.customFieldValues ?? null
|
} = useCustomFieldInputs({
|
||||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
definitions: computed(() => selectedTypeStructure.value?.customFields ?? []),
|
||||||
}
|
values: computed(() => component.value?.customFieldValues ?? []),
|
||||||
|
entityType: 'composant',
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
entityId: computed(() => component.value?.id ?? null),
|
||||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
context: 'standalone',
|
||||||
)
|
onValueCreated: (newValue) => {
|
||||||
|
if (component.value && Array.isArray(component.value.customFieldValues)) {
|
||||||
|
component.value.customFieldValues.push(newValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
canEdit.value
|
canEdit.value
|
||||||
@@ -239,8 +236,7 @@ export function useComponentEdit(componentId: string) {
|
|||||||
component.value = result.data
|
component.value = result.data
|
||||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||||
|
|
||||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
// The watcher on useCustomFieldInputs will auto-refresh when component.value changes
|
||||||
refreshCustomFieldInputs(undefined, customValues)
|
|
||||||
|
|
||||||
loadHistory(result.data.id).catch(() => {})
|
loadHistory(result.data.id).catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -392,14 +388,10 @@ export function useComponentEdit(componentId: string) {
|
|||||||
const result = await updateComposant(component.value.id, payload)
|
const result = await updateComposant(component.value.id, payload)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const updatedComponent = result.data as Record<string, any>
|
const updatedComponent = result.data as Record<string, any>
|
||||||
await _saveCustomFieldValues(
|
const failedFields = await saveAllCustomFields()
|
||||||
'composant',
|
if (failedFields.length) {
|
||||||
updatedComponent.id,
|
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
|
||||||
[
|
}
|
||||||
updatedComponent?.typeComposant?.structure?.customFields,
|
|
||||||
],
|
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save slot edits
|
// Save slot edits
|
||||||
const slotPromises: Promise<any>[] = []
|
const slotPromises: Promise<any>[] = []
|
||||||
@@ -499,7 +491,7 @@ export function useComponentEdit(componentId: string) {
|
|||||||
initialized.value = true
|
initialized.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
|
// useCustomFieldInputs auto-refreshes via its watcher on definitions + values
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -565,6 +557,7 @@ export function useComponentEdit(componentId: string) {
|
|||||||
originalConstructeurLinks,
|
originalConstructeurLinks,
|
||||||
constructeurIdsFromForm,
|
constructeurIdsFromForm,
|
||||||
customFieldInputs,
|
customFieldInputs,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
historyFieldLabels,
|
historyFieldLabels,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Backward-compatible wrapper around useEntityHistory.
|
|
||||||
* Real logic lives in useEntityHistory.ts.
|
|
||||||
*/
|
|
||||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
|
||||||
|
|
||||||
export type ComponentHistoryActor = EntityHistoryActor
|
|
||||||
export type ComponentHistoryEntry = EntityHistoryEntry
|
|
||||||
|
|
||||||
export function useComponentHistory() {
|
|
||||||
return useEntityHistory('composant')
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { useApi } from './useApi'
|
|||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
export interface Composant {
|
export interface Composant {
|
||||||
id: string
|
id: string
|
||||||
@@ -51,17 +51,6 @@ const total = ref(0)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
|
||||||
const p = payload as Record<string, unknown> | null
|
|
||||||
if (typeof p?.totalItems === 'number') {
|
|
||||||
return p.totalItems
|
|
||||||
}
|
|
||||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
|
||||||
return p['hydra:totalItems']
|
|
||||||
}
|
|
||||||
return fallbackLength
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useComposants() {
|
export function useComposants() {
|
||||||
const { showSuccess } = useToast()
|
const { showSuccess } = useToast()
|
||||||
const { get, post, patch, delete: del } = useApi()
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
|||||||
205
frontend/app/composables/useCustomFieldInputs.ts
Normal file
205
frontend/app/composables/useCustomFieldInputs.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Unified reactive custom field management composable.
|
||||||
|
*
|
||||||
|
* Replaces: useEntityCustomFields.ts, custom field parts of useMachineDetailCustomFields.ts,
|
||||||
|
* and inline custom field logic in useComponentEdit/useComponentCreate/usePieceEdit.
|
||||||
|
*
|
||||||
|
* DESIGN NOTE: Uses an internal mutable `ref` (not a `computed`) so that
|
||||||
|
* save operations can update `customFieldValueId` in place without being
|
||||||
|
* overwritten on the next reactivity cycle. Call `refresh()` to re-merge
|
||||||
|
* from the source definitions + values (e.g. after fetching fresh data).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, watch, computed, type MaybeRef, toValue } from 'vue'
|
||||||
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import {
|
||||||
|
mergeDefinitionsWithValues,
|
||||||
|
filterByContext,
|
||||||
|
formatValueForSave,
|
||||||
|
shouldPersist,
|
||||||
|
requiredFieldsFilled,
|
||||||
|
type CustomFieldDefinition,
|
||||||
|
type CustomFieldValue,
|
||||||
|
type CustomFieldInput,
|
||||||
|
} from '~/shared/utils/customFields'
|
||||||
|
|
||||||
|
export type { CustomFieldDefinition, CustomFieldValue, CustomFieldInput }
|
||||||
|
|
||||||
|
export type CustomFieldEntityType =
|
||||||
|
| 'machine'
|
||||||
|
| 'composant'
|
||||||
|
| 'piece'
|
||||||
|
| 'product'
|
||||||
|
| 'machineComponentLink'
|
||||||
|
| 'machinePieceLink'
|
||||||
|
|
||||||
|
export interface UseCustomFieldInputsOptions {
|
||||||
|
/** Custom field definitions (from ModelType structure or machine.customFields) */
|
||||||
|
definitions: MaybeRef<any[]>
|
||||||
|
/** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */
|
||||||
|
values: MaybeRef<any[]>
|
||||||
|
/** Entity type for API upsert calls */
|
||||||
|
entityType: CustomFieldEntityType
|
||||||
|
/** Entity ID for API upsert calls */
|
||||||
|
entityId: MaybeRef<string | null>
|
||||||
|
/** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */
|
||||||
|
context?: 'standalone' | 'machine'
|
||||||
|
/** Optional callback to update the source values array after a save (keeps parent reactive state in sync) */
|
||||||
|
onValueCreated?: (newValue: { id: string; value: string; customField: any }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
|
||||||
|
const { entityType, context } = options
|
||||||
|
const {
|
||||||
|
updateCustomFieldValue: updateApi,
|
||||||
|
upsertCustomFieldValue,
|
||||||
|
} = useCustomFields()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
|
||||||
|
// Internal mutable state — NOT a computed, so save can mutate in place
|
||||||
|
const _allFields = ref<CustomFieldInput[]>([])
|
||||||
|
|
||||||
|
// Re-merge from source definitions + values
|
||||||
|
const refresh = () => {
|
||||||
|
const defs = toValue(options.definitions)
|
||||||
|
const vals = toValue(options.values)
|
||||||
|
_allFields.value = mergeDefinitionsWithValues(defs, vals)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh when reactive sources change
|
||||||
|
watch(
|
||||||
|
() => [toValue(options.definitions), toValue(options.values)],
|
||||||
|
() => refresh(),
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filtered by context (standalone vs machine)
|
||||||
|
const fields = computed<CustomFieldInput[]>(() => {
|
||||||
|
if (!context) return _allFields.value
|
||||||
|
return filterByContext(_allFields.value, context)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const requiredFilled = computed(() => requiredFieldsFilled(fields.value))
|
||||||
|
|
||||||
|
// Build metadata for upsert when no customFieldId is available (legacy fallback)
|
||||||
|
const _buildMetadata = (field: CustomFieldInput) => ({
|
||||||
|
customFieldName: field.name,
|
||||||
|
customFieldType: field.type,
|
||||||
|
customFieldRequired: field.required,
|
||||||
|
customFieldOptions: field.options,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update a single field value
|
||||||
|
const update = async (field: CustomFieldInput): Promise<boolean> => {
|
||||||
|
const id = toValue(options.entityId)
|
||||||
|
if (!id) {
|
||||||
|
showError(`Impossible de sauvegarder le champ "${field.name}"`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = formatValueForSave(field)
|
||||||
|
|
||||||
|
// Update existing value
|
||||||
|
if (field.customFieldValueId) {
|
||||||
|
const result: any = await updateApi(field.customFieldValueId, { value })
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess(`Champ "${field.name}" mis à jour`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new value via upsert — with metadata fallback when no ID
|
||||||
|
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
|
||||||
|
const result: any = await upsertCustomFieldValue(
|
||||||
|
field.customFieldId,
|
||||||
|
entityType,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Mutate in place (safe — _allFields is a ref, not computed)
|
||||||
|
if (result.data?.id) {
|
||||||
|
field.customFieldValueId = result.data.id
|
||||||
|
}
|
||||||
|
if (result.data?.customField?.id) {
|
||||||
|
field.customFieldId = result.data.customField.id
|
||||||
|
}
|
||||||
|
// Notify parent to update its reactive source
|
||||||
|
if (options.onValueCreated && result.data) {
|
||||||
|
options.onValueCreated(result.data)
|
||||||
|
}
|
||||||
|
showSuccess(`Champ "${field.name}" enregistré`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(`Erreur lors de l'enregistrement du champ "${field.name}"`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save all fields that have values
|
||||||
|
const saveAll = async (): Promise<string[]> => {
|
||||||
|
const id = toValue(options.entityId)
|
||||||
|
if (!id) return ['(entity ID missing)']
|
||||||
|
|
||||||
|
const failed: string[] = []
|
||||||
|
|
||||||
|
for (const field of fields.value) {
|
||||||
|
if (!shouldPersist(field)) continue
|
||||||
|
|
||||||
|
const value = formatValueForSave(field)
|
||||||
|
|
||||||
|
if (field.customFieldValueId) {
|
||||||
|
const result: any = await updateApi(field.customFieldValueId, { value })
|
||||||
|
if (!result.success) failed.push(field.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert with metadata fallback when no customFieldId
|
||||||
|
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
|
||||||
|
const result: any = await upsertCustomFieldValue(
|
||||||
|
field.customFieldId,
|
||||||
|
entityType,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (result.data?.id) {
|
||||||
|
field.customFieldValueId = result.data.id
|
||||||
|
}
|
||||||
|
if (result.data?.customField?.id) {
|
||||||
|
field.customFieldId = result.data.customField.id
|
||||||
|
}
|
||||||
|
if (options.onValueCreated && result.data) {
|
||||||
|
options.onValueCreated(result.data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failed.push(field.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** All merged fields filtered by context */
|
||||||
|
fields,
|
||||||
|
/** All merged fields (unfiltered) */
|
||||||
|
allFields: _allFields,
|
||||||
|
/** Whether all required fields have values */
|
||||||
|
requiredFilled,
|
||||||
|
/** Update a single field value via API */
|
||||||
|
update,
|
||||||
|
/** Save all fields with values, returns list of failed field names */
|
||||||
|
saveAll,
|
||||||
|
/** Re-merge from source definitions + values (call after fetching fresh data) */
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
id: string
|
id: string
|
||||||
@@ -58,13 +58,6 @@ const total = ref(0)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
|
||||||
const p = payload as Record<string, unknown> | null
|
|
||||||
if (typeof p?.totalItems === 'number') return p.totalItems
|
|
||||||
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
|
|
||||||
return fallbackLength
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDocuments() {
|
export function useDocuments() {
|
||||||
const { get, patch, postFormData, delete: del } = useApi()
|
const { get, patch, postFormData, delete: del } = useApi()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* Reactive custom field management for entity items (ComponentItem, PieceItem).
|
|
||||||
*
|
|
||||||
* Wraps the pure logic from entityCustomFieldLogic.ts with Vue reactivity,
|
|
||||||
* watchers, and API calls for updating/upserting custom field values.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { computed, watch } from 'vue'
|
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
import {
|
|
||||||
buildDefinitionSources,
|
|
||||||
buildCandidateCustomFields,
|
|
||||||
mergeFieldDefinitionsWithValues,
|
|
||||||
dedupeMergedFields,
|
|
||||||
ensureCustomFieldId,
|
|
||||||
resolveFieldId,
|
|
||||||
resolveFieldName,
|
|
||||||
resolveFieldType,
|
|
||||||
resolveFieldReadOnly,
|
|
||||||
resolveCustomFieldId,
|
|
||||||
buildCustomFieldMetadata,
|
|
||||||
} from '~/shared/utils/entityCustomFieldLogic'
|
|
||||||
|
|
||||||
export interface EntityCustomFieldsDeps {
|
|
||||||
entity: () => any
|
|
||||||
entityType: 'composant' | 'piece'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEntityCustomFields(deps: EntityCustomFieldsDeps) {
|
|
||||||
const { entity, entityType } = deps
|
|
||||||
const {
|
|
||||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
|
||||||
upsertCustomFieldValue,
|
|
||||||
} = useCustomFields()
|
|
||||||
const { showSuccess, showError } = useToast()
|
|
||||||
|
|
||||||
const definitionSources = computed(() =>
|
|
||||||
buildDefinitionSources(entity(), entityType),
|
|
||||||
)
|
|
||||||
|
|
||||||
const displayedCustomFields = computed(() =>
|
|
||||||
dedupeMergedFields(
|
|
||||||
mergeFieldDefinitionsWithValues(
|
|
||||||
definitionSources.value,
|
|
||||||
entity().customFieldValues,
|
|
||||||
),
|
|
||||||
).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly),
|
|
||||||
)
|
|
||||||
|
|
||||||
const candidateCustomFields = computed(() =>
|
|
||||||
buildCandidateCustomFields(entity(), definitionSources.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Watchers to ensure field IDs are resolved
|
|
||||||
watch(
|
|
||||||
candidateCustomFields,
|
|
||||||
() => {
|
|
||||||
const candidates = candidateCustomFields.value
|
|
||||||
;(displayedCustomFields.value || []).forEach((field: any) => {
|
|
||||||
if (field) ensureCustomFieldId(field, candidates)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ immediate: true, deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
displayedCustomFields,
|
|
||||||
(fields) => {
|
|
||||||
const candidates = candidateCustomFields.value
|
|
||||||
;(fields || []).forEach((field: any) => {
|
|
||||||
if (field) ensureCustomFieldId(field, candidates)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ immediate: true, deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const updateCustomField = async (field: any) => {
|
|
||||||
if (!field || resolveFieldReadOnly(field)) return
|
|
||||||
|
|
||||||
const e = entity()
|
|
||||||
const fieldValueId = resolveFieldId(field)
|
|
||||||
|
|
||||||
// Update existing field value
|
|
||||||
if (fieldValueId) {
|
|
||||||
const result: any = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
|
||||||
if (result.success) {
|
|
||||||
const existingValue = e.customFieldValues?.find((v: any) => v.id === fieldValueId)
|
|
||||||
if (existingValue?.customField?.id) {
|
|
||||||
field.customFieldId = existingValue.customField.id
|
|
||||||
field.customField = existingValue.customField
|
|
||||||
}
|
|
||||||
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
|
|
||||||
} else {
|
|
||||||
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new field value
|
|
||||||
const customFieldId = ensureCustomFieldId(field, candidateCustomFields.value)
|
|
||||||
const fieldName = resolveFieldName(field)
|
|
||||||
if (!e?.id) {
|
|
||||||
showError(`Impossible de créer la valeur pour ce champ`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
|
|
||||||
showError(`Impossible de créer la valeur pour ce champ`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
|
|
||||||
const result: any = await upsertCustomFieldValue(
|
|
||||||
customFieldId,
|
|
||||||
entityType,
|
|
||||||
e.id,
|
|
||||||
field.value ?? '',
|
|
||||||
metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const newValue = result.data
|
|
||||||
if (newValue?.id) {
|
|
||||||
field.customFieldValueId = newValue.id
|
|
||||||
field.value = newValue.value ?? field.value ?? ''
|
|
||||||
if (newValue.customField?.id) {
|
|
||||||
field.customFieldId = newValue.customField.id
|
|
||||||
field.customField = newValue.customField
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(e.customFieldValues)) {
|
|
||||||
const index = e.customFieldValues.findIndex((v: any) => v.id === newValue.id)
|
|
||||||
if (index !== -1) {
|
|
||||||
e.customFieldValues.splice(index, 1, newValue)
|
|
||||||
} else {
|
|
||||||
e.customFieldValues.push(newValue)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
e.customFieldValues = [newValue]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
|
|
||||||
|
|
||||||
// Update definitions list
|
|
||||||
const definitions = Array.isArray(e.customFields) ? [...e.customFields] : []
|
|
||||||
const fieldIdentifier = ensureCustomFieldId(field, candidateCustomFields.value)
|
|
||||||
const existingIndex = definitions.findIndex((definition: any) => {
|
|
||||||
const definitionId = resolveCustomFieldId(definition)
|
|
||||||
if (fieldIdentifier && definitionId) return definitionId === fieldIdentifier
|
|
||||||
return definition?.name === resolveFieldName(field)
|
|
||||||
})
|
|
||||||
|
|
||||||
const updatedDefinition = {
|
|
||||||
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
|
|
||||||
customFieldValueId: field.customFieldValueId,
|
|
||||||
customFieldId: fieldIdentifier,
|
|
||||||
name: resolveFieldName(field),
|
|
||||||
type: resolveFieldType(field),
|
|
||||||
required: field.required ?? false,
|
|
||||||
options: field.options ?? [],
|
|
||||||
value: field.value ?? '',
|
|
||||||
customField: field.customField ?? null,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingIndex !== -1) {
|
|
||||||
definitions.splice(existingIndex, 1, updatedDefinition)
|
|
||||||
} else {
|
|
||||||
definitions.push(updatedDefinition)
|
|
||||||
}
|
|
||||||
e.customFields = definitions
|
|
||||||
} else {
|
|
||||||
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
displayedCustomFields,
|
|
||||||
candidateCustomFields,
|
|
||||||
updateCustomField,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,14 +8,12 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { normalizeStructureForEditor } from '~/shared/modelUtils'
|
|
||||||
import {
|
import {
|
||||||
shouldDisplayCustomField,
|
mergeDefinitionsWithValues,
|
||||||
normalizeExistingCustomFieldDefinitions,
|
filterByContext,
|
||||||
normalizeCustomFieldValueEntry,
|
hasDisplayableValue,
|
||||||
mergeCustomFieldValuesWithDefinitions,
|
type CustomFieldInput,
|
||||||
dedupeCustomFieldEntries,
|
} from '~/shared/utils/customFields'
|
||||||
} from '~/shared/utils/customFieldUtils'
|
|
||||||
import {
|
import {
|
||||||
resolveConstructeurs,
|
resolveConstructeurs,
|
||||||
uniqueConstructeurIds,
|
uniqueConstructeurIds,
|
||||||
@@ -53,56 +51,23 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
|||||||
const visibleMachineCustomFields = computed(() => {
|
const visibleMachineCustomFields = computed(() => {
|
||||||
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
|
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
|
||||||
if (isEditMode.value) return fields
|
if (isEditMode.value) return fields
|
||||||
return fields.filter((field) => shouldDisplayCustomField(field))
|
return fields.filter((field) => hasDisplayableValue(field as unknown as CustomFieldInput))
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Transform helpers
|
// Transform helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const getStructureCustomFields = (structure: unknown): AnyRecord[] => {
|
|
||||||
if (!structure || typeof structure !== 'object') return []
|
|
||||||
const normalized = normalizeStructureForEditor(structure as any) as any
|
|
||||||
return Array.isArray(normalized?.customFields)
|
|
||||||
? (normalized.customFields as AnyRecord[])
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
|
|
||||||
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
|
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
|
||||||
return (piecesData || []).map((piece) => {
|
return (piecesData || []).map((piece) => {
|
||||||
const typePiece = (piece.typePiece as AnyRecord) || {}
|
const typePiece = (piece.typePiece as AnyRecord) || {}
|
||||||
|
|
||||||
const normalizeStructureDefs = (structure: unknown) =>
|
const customFields = filterByContext(
|
||||||
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
mergeDefinitionsWithValues(
|
||||||
|
typePiece.customFields ?? (piece.typePiece as AnyRecord)?.customFields ?? [],
|
||||||
const normalizedStructureDefs = [
|
piece.customFieldValues ?? [],
|
||||||
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
|
|
||||||
normalizeStructureDefs(typePiece.structure),
|
|
||||||
]
|
|
||||||
|
|
||||||
const valueEntries = [
|
|
||||||
...(Array.isArray(piece.customFieldValues) ? piece.customFieldValues : []),
|
|
||||||
...(Array.isArray(piece.customFields)
|
|
||||||
? (piece.customFields as AnyRecord[])
|
|
||||||
.map(normalizeCustomFieldValueEntry)
|
|
||||||
.filter((e) => e !== null)
|
|
||||||
: []),
|
|
||||||
...(Array.isArray(typePiece.customFieldValues)
|
|
||||||
? (typePiece.customFieldValues as AnyRecord[])
|
|
||||||
.map(normalizeCustomFieldValueEntry)
|
|
||||||
.filter((e) => e !== null)
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
|
|
||||||
const customFields = dedupeCustomFieldEntries(
|
|
||||||
mergeCustomFieldValuesWithDefinitions(
|
|
||||||
valueEntries,
|
|
||||||
normalizeExistingCustomFieldDefinitions(piece.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
|
|
||||||
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
|
|
||||||
),
|
),
|
||||||
|
'standalone',
|
||||||
)
|
)
|
||||||
|
|
||||||
const constructeurIds = uniqueConstructeurIds(
|
const constructeurIds = uniqueConstructeurIds(
|
||||||
@@ -159,43 +124,16 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
|||||||
}
|
}
|
||||||
|
|
||||||
const transformComponentCustomFields = (componentsData: AnyRecord[]): AnyRecord[] => {
|
const transformComponentCustomFields = (componentsData: AnyRecord[]): AnyRecord[] => {
|
||||||
const normalizeStructureDefs = (structure: unknown) =>
|
|
||||||
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
|
||||||
|
|
||||||
return (componentsData || []).map((component) => {
|
return (componentsData || []).map((component) => {
|
||||||
const type = (component.typeComposant as AnyRecord) || {}
|
const type = (component.typeComposant as AnyRecord) || {}
|
||||||
|
|
||||||
const normalizedStructureDefs = [
|
|
||||||
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
|
|
||||||
normalizeStructureDefs(type.structure),
|
|
||||||
]
|
|
||||||
|
|
||||||
const actualComponent = (component.originalComposant as AnyRecord) || component
|
const actualComponent = (component.originalComposant as AnyRecord) || component
|
||||||
|
|
||||||
const valueEntries = [
|
const customFields = filterByContext(
|
||||||
...(Array.isArray(component.customFieldValues) ? component.customFieldValues : []),
|
mergeDefinitionsWithValues(
|
||||||
...(Array.isArray(component.customFields)
|
type.customFields ?? [],
|
||||||
? (component.customFields as AnyRecord[])
|
component.customFieldValues ?? actualComponent?.customFieldValues ?? [],
|
||||||
.map(normalizeCustomFieldValueEntry)
|
|
||||||
.filter((e) => e !== null)
|
|
||||||
: []),
|
|
||||||
...(Array.isArray(actualComponent?.customFields)
|
|
||||||
? (actualComponent.customFields as AnyRecord[])
|
|
||||||
.map(normalizeCustomFieldValueEntry)
|
|
||||||
.filter((e) => e !== null)
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
|
|
||||||
const customFields = dedupeCustomFieldEntries(
|
|
||||||
mergeCustomFieldValuesWithDefinitions(
|
|
||||||
valueEntries,
|
|
||||||
normalizeExistingCustomFieldDefinitions(component.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions((component.definition as AnyRecord)?.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions(type.customFields),
|
|
||||||
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
|
|
||||||
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
|
|
||||||
),
|
),
|
||||||
|
'standalone',
|
||||||
)
|
)
|
||||||
|
|
||||||
const piecesTransformed = component.pieces
|
const piecesTransformed = component.pieces
|
||||||
@@ -271,21 +209,11 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
|||||||
machineCustomFields.value = []
|
machineCustomFields.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const valueEntries = [
|
const merged = mergeDefinitionsWithValues(
|
||||||
...(Array.isArray(machine.value.customFieldValues) ? machine.value.customFieldValues : []),
|
machine.value?.customFields ?? [],
|
||||||
...(Array.isArray(machine.value.customFields)
|
machine.value?.customFieldValues ?? [],
|
||||||
? (machine.value.customFields as AnyRecord[])
|
)
|
||||||
.map(normalizeCustomFieldValueEntry)
|
machineCustomFields.value = merged.map(f => ({ ...f, readOnly: false }))
|
||||||
.filter((e) => e !== null)
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
const merged = dedupeCustomFieldEntries(
|
|
||||||
mergeCustomFieldValuesWithDefinitions(
|
|
||||||
valueEntries,
|
|
||||||
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
|
|
||||||
),
|
|
||||||
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
|
|
||||||
machineCustomFields.value = merged
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setMachineCustomFieldValue = (field: AnyRecord, value: unknown) => {
|
const setMachineCustomFieldValue = (field: AnyRecord, value: unknown) => {
|
||||||
@@ -302,7 +230,8 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
|||||||
const updateMachineCustomField = async (field: AnyRecord) => {
|
const updateMachineCustomField = async (field: AnyRecord) => {
|
||||||
if (!machine.value || !field) return
|
if (!machine.value || !field) return
|
||||||
|
|
||||||
const { id: customFieldId, customFieldValueId } = field
|
const customFieldId = (field.customFieldId ?? field.id) as string | undefined
|
||||||
|
const customFieldValueId = field.customFieldValueId as string | undefined
|
||||||
const fieldLabel = (field.name as string) || 'Champ personnalisé'
|
const fieldLabel = (field.name as string) || 'Champ personnalisé'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -467,7 +396,8 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (const field of fieldsToSave) {
|
for (const field of fieldsToSave) {
|
||||||
const { id: customFieldId, customFieldValueId } = field
|
const customFieldId = (field.customFieldId ?? field.id) as string | undefined
|
||||||
|
const customFieldValueId = field.customFieldValueId as string | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (customFieldValueId) {
|
if (customFieldValueId) {
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
if (!machineName.value.trim()) return false
|
if (!machineName.value.trim()) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
const debug = ref(false)
|
|
||||||
|
|
||||||
const componentsCollapsed = ref(true)
|
const componentsCollapsed = ref(true)
|
||||||
const collapseToggleToken = ref(0)
|
const collapseToggleToken = ref(0)
|
||||||
@@ -227,22 +226,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
const componentTypeOptions = computed(() => componentTypes.value || [])
|
const componentTypeOptions = computed(() => componentTypes.value || [])
|
||||||
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
||||||
|
|
||||||
const componentTypeLabelMap = computed(() => {
|
|
||||||
const map = new Map<string, string>()
|
|
||||||
componentTypeOptions.value.forEach((type) => {
|
|
||||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieceTypeLabelMap = computed(() => {
|
|
||||||
const map = new Map<string, string>()
|
|
||||||
pieceTypeOptions.value.forEach((type) => {
|
|
||||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
// Machine field methods
|
// Machine field methods
|
||||||
const initMachineFields = () => {
|
const initMachineFields = () => {
|
||||||
if (machine.value) {
|
if (machine.value) {
|
||||||
@@ -306,7 +289,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
// UI methods
|
// UI methods
|
||||||
const toggleEditMode = () => {
|
const toggleEditMode = () => {
|
||||||
isEditMode.value = !isEditMode.value
|
isEditMode.value = !isEditMode.value
|
||||||
debug.value = !debug.value
|
|
||||||
if (isEditMode.value && !machineDocumentsLoaded.value) {
|
if (isEditMode.value && !machineDocumentsLoaded.value) {
|
||||||
refreshMachineDocuments()
|
refreshMachineDocuments()
|
||||||
}
|
}
|
||||||
@@ -432,12 +414,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
await productsPromise
|
await productsPromise
|
||||||
const linksApplied = applyMachineLinks(machineResult.data)
|
const linksApplied = applyMachineLinks(machineResult.data)
|
||||||
|
|
||||||
if (machine.value) {
|
|
||||||
machine.value.componentLinks = machineComponentLinks.value
|
|
||||||
machine.value.pieceLinks = machinePieceLinks.value
|
|
||||||
machine.value.productLinks = machineProductLinks.value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!linksApplied) {
|
if (!linksApplied) {
|
||||||
components.value = transformComponentCustomFields(machinePayload.components || [])
|
components.value = transformComponentCustomFields(machinePayload.components || [])
|
||||||
pieces.value = transformCustomFields(machinePayload.pieces || [])
|
pieces.value = transformCustomFields(machinePayload.pieces || [])
|
||||||
@@ -447,6 +423,8 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (machine.value) {
|
if (machine.value) {
|
||||||
|
machine.value.componentLinks = machineComponentLinks.value
|
||||||
|
machine.value.pieceLinks = machinePieceLinks.value
|
||||||
machine.value.productLinks = machineProductLinks.value
|
machine.value.productLinks = machineProductLinks.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,11 +474,11 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
// UI state
|
// UI state
|
||||||
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
||||||
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
||||||
isEditMode, debug,
|
isEditMode,
|
||||||
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap,
|
componentTypeOptions, pieceTypeOptions,
|
||||||
productInventory, productById, flattenedComponents, machinePieces,
|
productInventory, productById, flattenedComponents, machinePieces,
|
||||||
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { useRouter } from '#imports'
|
import { useRouter } from '#imports'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||||
import { usePieceHistory } from '~/composables/usePieceHistory'
|
import { useEntityHistory } from '~/composables/useEntityHistory'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||||
@@ -21,17 +20,11 @@ import {
|
|||||||
buildProductRequirementDescriptions,
|
buildProductRequirementDescriptions,
|
||||||
buildProductRequirementEntries,
|
buildProductRequirementEntries,
|
||||||
resizeProductSelections,
|
resizeProductSelections,
|
||||||
areProductSelectionsFilled,
|
|
||||||
applyProductSelection,
|
applyProductSelection,
|
||||||
collectNormalizedProductIds,
|
collectNormalizedProductIds,
|
||||||
} from '~/shared/utils/pieceProductSelectionUtils'
|
} from '~/shared/utils/pieceProductSelectionUtils'
|
||||||
import { getModelType } from '~/services/modelTypes'
|
import { getModelType } from '~/services/modelTypes'
|
||||||
import {
|
import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
|
||||||
type CustomFieldInput,
|
|
||||||
buildCustomFieldInputs,
|
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
|
||||||
|
|
||||||
interface PieceCatalogType extends ModelType {
|
interface PieceCatalogType extends ModelType {
|
||||||
structure: PieceModelStructure | null
|
structure: PieceModelStructure | null
|
||||||
@@ -44,7 +37,6 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { updatePiece } = usePieces()
|
const { updatePiece } = usePieces()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
@@ -54,7 +46,7 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
loading: historyLoading,
|
loading: historyLoading,
|
||||||
error: historyError,
|
error: historyError,
|
||||||
loadHistory,
|
loadHistory,
|
||||||
} = usePieceHistory()
|
} = useEntityHistory('piece')
|
||||||
|
|
||||||
const piece = ref<any | null>(null)
|
const piece = ref<any | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -90,19 +82,29 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||||
const productSelections = ref<(string | null)[]>([])
|
const productSelections = ref<(string | null)[]>([])
|
||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
// Declared early so useCustomFieldInputs can reference it.
|
||||||
|
// selectedType is defined later but is safely accessed inside a computed (lazy evaluation).
|
||||||
const resolvedStructure = computed<PieceModelStructure | null>(() =>
|
const resolvedStructure = computed<PieceModelStructure | null>(() =>
|
||||||
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
|
pieceTypeDetails.value?.structure ?? null,
|
||||||
)
|
)
|
||||||
|
|
||||||
const refreshCustomFieldInputs = (
|
const {
|
||||||
structureOverride?: PieceModelStructure | null,
|
fields: customFieldInputs,
|
||||||
valuesOverride?: any[] | null,
|
requiredFilled: requiredCustomFieldsFilled,
|
||||||
) => {
|
saveAll: saveAllCustomFields,
|
||||||
const structure = structureOverride ?? resolvedStructure.value ?? null
|
refresh: refreshCustomFieldInputs,
|
||||||
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
|
} = useCustomFieldInputs({
|
||||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
definitions: computed(() => resolvedStructure.value?.customFields ?? []),
|
||||||
}
|
values: computed(() => piece.value?.customFieldValues ?? []),
|
||||||
|
entityType: 'piece',
|
||||||
|
entityId: computed(() => piece.value?.id ?? null),
|
||||||
|
context: 'standalone',
|
||||||
|
onValueCreated: (newValue) => {
|
||||||
|
if (piece.value && Array.isArray(piece.value.customFieldValues)) {
|
||||||
|
piece.value.customFieldValues.push(newValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const openPreview = (doc: any) => {
|
const openPreview = (doc: any) => {
|
||||||
if (!doc || !canPreviewDocument(doc)) {
|
if (!doc || !canPreviewDocument(doc)) {
|
||||||
@@ -196,13 +198,7 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
|
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const productSelectionsFilled = computed(() =>
|
const productSelectionsFilled = computed(() => true)
|
||||||
areProductSelectionsFilled(
|
|
||||||
requiresProductSelection.value,
|
|
||||||
productRequirementEntries.value,
|
|
||||||
productSelections.value,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const setProductSelection = (index: number, value: string | null) => {
|
const setProductSelection = (index: number, value: string | null) => {
|
||||||
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
||||||
@@ -221,10 +217,6 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
pendingProductIds = []
|
pendingProductIds = []
|
||||||
})
|
})
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
|
||||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
canEdit.value
|
canEdit.value
|
||||||
@@ -247,9 +239,7 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
piece.value = result.data
|
piece.value = result.data
|
||||||
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||||
|
|
||||||
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
// The watcher on useCustomFieldInputs will auto-refresh when piece.value changes
|
||||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
|
||||||
refreshCustomFieldInputs(undefined, customValues)
|
|
||||||
|
|
||||||
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
||||||
loadPieceTypeDetailsFromCache(result.data)
|
loadPieceTypeDetailsFromCache(result.data)
|
||||||
@@ -275,14 +265,14 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
|
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
|
||||||
if (cachedType) {
|
if (cachedType) {
|
||||||
pieceTypeDetails.value = cachedType
|
pieceTypeDetails.value = cachedType
|
||||||
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
// useCustomFieldInputs auto-refreshes via its watcher on resolvedStructure
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Fallback: fetch if not in cache (edge case)
|
// Fallback: fetch if not in cache (edge case)
|
||||||
getModelType(typeId).then((type) => {
|
getModelType(typeId).then((type) => {
|
||||||
if (type && typeof type === 'object') {
|
if (type && typeof type === 'object') {
|
||||||
pieceTypeDetails.value = type
|
pieceTypeDetails.value = type
|
||||||
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
// useCustomFieldInputs auto-refreshes via its watcher on resolvedStructure
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
pieceTypeDetails.value = null
|
pieceTypeDetails.value = null
|
||||||
@@ -336,29 +326,21 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
pendingProductIds = []
|
pendingProductIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// After setting selectedTypeId, read selectedType.value (now updated) instead of
|
// useCustomFieldInputs auto-refreshes via its watcher on definitions + values
|
||||||
// the stale destructured currentType which was captured before the ID change.
|
|
||||||
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
|
|
||||||
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
|
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(selectedType, (currentType) => {
|
// useCustomFieldInputs auto-refreshes when selectedType changes (via resolvedStructure)
|
||||||
if (!piece.value || !currentType) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(resolvedStructure, (currentStructure) => {
|
watch(resolvedStructure, () => {
|
||||||
if (!piece.value) {
|
if (!piece.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ensureProductSelections(structureProducts.value.length)
|
ensureProductSelections(structureProducts.value.length)
|
||||||
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
|
// useCustomFieldInputs auto-refreshes via its watcher on definitions
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitEdition = async () => {
|
const submitEdition = async () => {
|
||||||
@@ -366,11 +348,6 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!productSelectionsFilled.value) {
|
|
||||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPrice = typeof editionForm.prix === 'string'
|
const rawPrice = typeof editionForm.prix === 'string'
|
||||||
? editionForm.prix.trim()
|
? editionForm.prix.trim()
|
||||||
: editionForm.prix === null || editionForm.prix === undefined
|
: editionForm.prix === null || editionForm.prix === undefined
|
||||||
@@ -407,15 +384,10 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
try {
|
try {
|
||||||
const result = await updatePiece(piece.value.id, payload)
|
const result = await updatePiece(piece.value.id, payload)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const updatedPiece = result.data as Record<string, any>
|
const failedFields = await saveAllCustomFields()
|
||||||
await _saveCustomFieldValues(
|
if (failedFields.length) {
|
||||||
'piece',
|
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
|
||||||
updatedPiece.id,
|
}
|
||||||
[
|
|
||||||
updatedPiece?.typePiece?.structure?.customFields,
|
|
||||||
],
|
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
|
||||||
)
|
|
||||||
await syncLinks('piece', piece.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
await syncLinks('piece', piece.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
||||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||||
toast.showSuccess('Pièce mise à jour avec succès.')
|
toast.showSuccess('Pièce mise à jour avec succès.')
|
||||||
@@ -452,6 +424,7 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
constructeurIdsFromForm,
|
constructeurIdsFromForm,
|
||||||
productSelections,
|
productSelections,
|
||||||
customFieldInputs,
|
customFieldInputs,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Backward-compatible wrapper around useEntityHistory.
|
|
||||||
* Real logic lives in useEntityHistory.ts.
|
|
||||||
*/
|
|
||||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
|
||||||
|
|
||||||
export type PieceHistoryActor = EntityHistoryActor
|
|
||||||
export type PieceHistoryEntry = EntityHistoryEntry
|
|
||||||
|
|
||||||
export function usePieceHistory() {
|
|
||||||
return useEntityHistory('piece')
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { useApi } from './useApi'
|
|||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
export interface Piece {
|
export interface Piece {
|
||||||
id: string
|
id: string
|
||||||
@@ -53,17 +53,6 @@ const total = ref(0)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
|
||||||
const p = payload as Record<string, unknown> | null
|
|
||||||
if (typeof p?.totalItems === 'number') {
|
|
||||||
return p.totalItems
|
|
||||||
}
|
|
||||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
|
||||||
return p['hydra:totalItems']
|
|
||||||
}
|
|
||||||
return fallbackLength
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePieces() {
|
export function usePieces() {
|
||||||
const { showSuccess } = useToast()
|
const { showSuccess } = useToast()
|
||||||
const { get, post, patch, delete: del } = useApi()
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Backward-compatible wrapper around useEntityHistory.
|
|
||||||
* Real logic lives in useEntityHistory.ts.
|
|
||||||
*/
|
|
||||||
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
|
|
||||||
|
|
||||||
export type ProductHistoryActor = EntityHistoryActor
|
|
||||||
export type ProductHistoryEntry = EntityHistoryEntry
|
|
||||||
|
|
||||||
export function useProductHistory() {
|
|
||||||
return useEntityHistory('product')
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import { humanizeError } from '~/shared/utils/errorMessages'
|
|||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: string
|
id: string
|
||||||
@@ -66,17 +66,6 @@ const replaceInCache = (item: Product): boolean => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
|
||||||
const p = payload as Record<string, unknown> | null
|
|
||||||
if (typeof p?.totalItems === 'number') {
|
|
||||||
return p.totalItems
|
|
||||||
}
|
|
||||||
if (typeof p?.['hydra:totalItems'] === 'number') {
|
|
||||||
return p['hydra:totalItems']
|
|
||||||
}
|
|
||||||
return fallbackLength
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useProducts() {
|
export function useProducts() {
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
const { get, post, patch, delete: del } = useApi()
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Toast {
|
|||||||
message: string
|
message: string
|
||||||
type: ToastType
|
type: ToastType
|
||||||
visible: boolean
|
visible: boolean
|
||||||
|
duration: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const toasts = ref<Toast[]>([])
|
const toasts = ref<Toast[]>([])
|
||||||
@@ -32,6 +33,7 @@ export function useToast() {
|
|||||||
message,
|
message,
|
||||||
type,
|
type,
|
||||||
visible: true,
|
visible: true,
|
||||||
|
duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toasts.value.length >= MAX_TOASTS) {
|
if (toasts.value.length >= MAX_TOASTS) {
|
||||||
@@ -40,10 +42,11 @@ export function useToast() {
|
|||||||
|
|
||||||
toasts.value.push(toast)
|
toasts.value.push(toast)
|
||||||
|
|
||||||
// Auto-remove after duration
|
if (duration > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
removeToast(id)
|
removeToast(id)
|
||||||
}, duration)
|
}, duration)
|
||||||
|
}
|
||||||
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
@@ -52,7 +55,7 @@ export function useToast() {
|
|||||||
return showToast(message, 'success', duration)
|
return showToast(message, 'success', duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showError = (message: string, duration = 5000): number => {
|
const showError = (message: string, duration = 8000): number => {
|
||||||
return showToast(message, 'error', duration)
|
return showToast(message, 'error', duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
frontend/app/composables/useUnsavedGuard.ts
Normal file
32
frontend/app/composables/useUnsavedGuard.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
export function useUnsavedGuard(isDirty: Ref<boolean>) {
|
||||||
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
|
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||||
|
if (isDirty.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeRouteLeave(async () => {
|
||||||
|
if (!isDirty.value) return true
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Modifications non sauvegardées',
|
||||||
|
message: 'Vous avez des modifications en cours. Voulez-vous quitter sans sauvegarder ?',
|
||||||
|
confirmText: 'Quitter sans sauver',
|
||||||
|
cancelText: 'Rester',
|
||||||
|
dangerous: true,
|
||||||
|
})
|
||||||
|
return ok
|
||||||
|
})
|
||||||
|
}
|
||||||
52
frontend/app/composables/useUsedIn.ts
Normal file
52
frontend/app/composables/useUsedIn.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
interface UsedInMachine {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
site?: { id: string; name: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsedInEntity {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsedInData {
|
||||||
|
machines: UsedInMachine[]
|
||||||
|
composants: UsedInEntity[]
|
||||||
|
pieces: UsedInEntity[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsedIn(entityType: Ref<'composants' | 'pieces' | 'products'>, entityId: Ref<string | null>) {
|
||||||
|
const data = ref<UsedInData>({ machines: [], composants: [], pieces: [] })
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!entityId.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.get(`/${entityType.value}/${entityId.value}/used-in`)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
data.value = {
|
||||||
|
machines: result.data.machines || [],
|
||||||
|
composants: result.data.composants || [],
|
||||||
|
pieces: result.data.pieces || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = computed(() =>
|
||||||
|
data.value.machines.length + data.value.composants.length + data.value.pieces.length
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(entityId, (val) => {
|
||||||
|
if (val) load()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
return { data, loading, totalCount, load }
|
||||||
|
}
|
||||||
24
frontend/app/middleware/legacy-redirects.global.ts
Normal file
24
frontend/app/middleware/legacy-redirects.global.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
|
const redirects: Record<string, string> = {
|
||||||
|
'/component-catalog': '/catalogues/composants',
|
||||||
|
'/pieces-catalog': '/catalogues/pieces',
|
||||||
|
'/product-catalog': '/catalogues/produits',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact path match redirects
|
||||||
|
const redirect = redirects[to.path]
|
||||||
|
if (redirect) {
|
||||||
|
return navigateTo({ path: redirect, query: to.query }, { redirectCode: 301 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category index redirects (add tab=categories query param)
|
||||||
|
if (to.path === '/component-category') {
|
||||||
|
return navigateTo({ path: '/catalogues/composants', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
|
||||||
|
}
|
||||||
|
if (to.path === '/piece-category') {
|
||||||
|
return navigateTo({ path: '/catalogues/pieces', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
|
||||||
|
}
|
||||||
|
if (to.path === '/product-category') {
|
||||||
|
return navigateTo({ path: '/catalogues/produits', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
<option value="piece">Pièce</option>
|
<option value="piece">Pièce</option>
|
||||||
<option value="product">Produit</option>
|
<option value="product">Produit</option>
|
||||||
<option value="composant">Composant</option>
|
<option value="composant">Composant</option>
|
||||||
|
<option value="machine">Machine</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,13 +90,16 @@
|
|||||||
|
|
||||||
<template #cell-entity="{ row }">
|
<template #cell-entity="{ row }">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="row.action !== 'delete'"
|
v-if="row.action !== 'delete' && entityEditLink(row) !== '#'"
|
||||||
:to="entityEditLink(row)"
|
:to="entityEditLink(row)"
|
||||||
class="link link-hover link-primary"
|
class="link link-hover link-primary"
|
||||||
>
|
>
|
||||||
{{ row.entityName || 'Sans nom' }}
|
{{ row.entityName || 'Sans nom' }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span v-else class="text-base-content/50 line-through">
|
<span v-else-if="row.action === 'delete'" class="text-base-content/50 line-through">
|
||||||
|
{{ row.entityName || 'Sans nom' }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
{{ row.entityName || 'Sans nom' }}
|
{{ row.entityName || 'Sans nom' }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -195,19 +199,23 @@ const ENTITY_TYPE_LABELS: Record<string, string> = {
|
|||||||
piece: 'Pièce',
|
piece: 'Pièce',
|
||||||
product: 'Produit',
|
product: 'Produit',
|
||||||
composant: 'Composant',
|
composant: 'Composant',
|
||||||
|
machine: 'Machine',
|
||||||
|
document: 'Document',
|
||||||
|
model_type: 'Modèle',
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
|
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
|
||||||
|
|
||||||
const ENTITY_EDIT_ROUTES: Record<string, string> = {
|
const ENTITY_ROUTES: Record<string, string> = {
|
||||||
piece: '/pieces',
|
piece: '/piece',
|
||||||
product: '/product',
|
product: '/product',
|
||||||
composant: '/component',
|
composant: '/component',
|
||||||
|
machine: '/machine',
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityEditLink = (entry: ActivityLogEntry) => {
|
const entityEditLink = (entry: ActivityLogEntry) => {
|
||||||
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
|
const base = ENTITY_ROUTES[entry.entityType] ?? ''
|
||||||
return base ? `${base}/${entry.entityId}/edit` : '#'
|
return base ? `${base}/${entry.entityId}` : '#'
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionBadgeClass = (action: string) => {
|
const actionBadgeClass = (action: string) => {
|
||||||
|
|||||||
239
frontend/app/pages/catalogues/composants.vue
Normal file
239
frontend/app/pages/catalogues/composants.vue
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-10">
|
||||||
|
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Composants</h1>
|
||||||
|
<p class="text-sm text-base-content/70">Catalogue et catégories de composants.</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/component-category/new' : '/component/create'" class="btn btn-primary btn-sm md:btn-md">
|
||||||
|
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter un composant' }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Composants">
|
||||||
|
<template #tab-catalogue>
|
||||||
|
<section class="card bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<header class="flex flex-col gap-1">
|
||||||
|
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
|
||||||
|
<p class="text-sm text-base-content/50">
|
||||||
|
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="componentRows"
|
||||||
|
:loading="loadingComposants"
|
||||||
|
:sort="table.sort.value"
|
||||||
|
:pagination="paginationState"
|
||||||
|
:column-filters="table.columnFilters.value"
|
||||||
|
:show-per-page="true"
|
||||||
|
empty-message="Aucun composant n'a encore été créé."
|
||||||
|
no-results-message="Aucun composant ne correspond à votre recherche."
|
||||||
|
@sort="table.handleSort"
|
||||||
|
@update:current-page="table.handlePageChange"
|
||||||
|
@update:per-page="table.handlePerPageChange"
|
||||||
|
@update:column-filters="table.handleColumnFiltersChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<label class="w-full sm:w-72">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
|
<input
|
||||||
|
v-model="table.searchTerm.value"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Nom ou référence…"
|
||||||
|
@input="table.debouncedSearch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-preview="{ row }">
|
||||||
|
<DocumentThumbnail
|
||||||
|
:document="resolvePrimaryDocument(row.component)"
|
||||||
|
:alt="resolvePreviewAlt(row.component)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
{{ row.component.name || 'Composant sans nom' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-reference="{ row }">
|
||||||
|
{{ row.component.reference || '—' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-referenceAuto="{ row }">
|
||||||
|
{{ row.component.referenceAuto || '—' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-description="{ row }">
|
||||||
|
<div v-if="row.component.description" class="group relative">
|
||||||
|
<span class="block cursor-help truncate">{{ row.component.description }}</span>
|
||||||
|
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
||||||
|
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-typeComposant="{ row }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="row.component.typeComposant?.id"
|
||||||
|
:to="`/component-category/${row.component.typeComposant.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ resolveComponentType(row.component) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ resolveComponentType(row.component) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-createdAt="{ row }">
|
||||||
|
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
:disabled="loadingComposants"
|
||||||
|
@click="handleDeleteComponent(row.component)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/component/${row.component.id}`"
|
||||||
|
class="btn btn-primary btn-xs"
|
||||||
|
>
|
||||||
|
Détails
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-categories>
|
||||||
|
<ManagementView
|
||||||
|
category="COMPONENT"
|
||||||
|
heading="Catégories de composant"
|
||||||
|
:hide-heading="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</EntityTabs>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
|
import ManagementView from '~/components/model-types/ManagementView.vue'
|
||||||
|
import { useComposants } from '~/composables/useComposants'
|
||||||
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
|
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||||
|
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
|
const activeTab = ref((route.query.tab as string) || 'catalogue')
|
||||||
|
watch(activeTab, (val) => {
|
||||||
|
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageTabs = computed(() => {
|
||||||
|
const t: Array<{ key: string; label: string }> = [
|
||||||
|
{ key: 'catalogue', label: 'Catalogue' },
|
||||||
|
]
|
||||||
|
if (canEdit.value) {
|
||||||
|
t.push({ key: 'categories', label: 'Catégories' })
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
})
|
||||||
|
|
||||||
|
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
|
||||||
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
|
|
||||||
|
const table = useDataTable(
|
||||||
|
{ fetchData: fetchComposants },
|
||||||
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence' },
|
||||||
|
{ key: 'referenceAuto', label: 'Réf. auto' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const composantsOnPage = computed(() => componentRows.value.length)
|
||||||
|
const paginationState = table.pagination(total, composantsOnPage)
|
||||||
|
|
||||||
|
const composantsList = computed(() => {
|
||||||
|
return (composants.value || []).map((composant) => {
|
||||||
|
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
||||||
|
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const componentRows = computed(() =>
|
||||||
|
composantsList.value.map(component => ({
|
||||||
|
id: component.id,
|
||||||
|
component,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function fetchComposants() {
|
||||||
|
await loadComposants({
|
||||||
|
search: table.searchTerm.value,
|
||||||
|
page: table.currentPage.value,
|
||||||
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
|
orderBy: table.sortField.value,
|
||||||
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
|
typeName: table.columnFilters.value.typeComposant || undefined,
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveComponentType = (component: Record<string, any>) => {
|
||||||
|
if (component?.typeComposant?.name) return component.typeComposant.name
|
||||||
|
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
|
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||||
|
const componentName = component?.name || 'ce composant'
|
||||||
|
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
|
||||||
|
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
|
||||||
|
if (!confirmed) return
|
||||||
|
await deleteComposant(component.id)
|
||||||
|
fetchComposants()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = formatFrenchDate
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
267
frontend/app/pages/catalogues/pieces.vue
Normal file
267
frontend/app/pages/catalogues/pieces.vue
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-10">
|
||||||
|
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Pièces</h1>
|
||||||
|
<p class="text-sm text-base-content/70">Catalogue et catégories de pièces.</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/piece-category/new' : '/pieces/create'" class="btn btn-primary btn-sm md:btn-md">
|
||||||
|
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter une pièce' }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Pièces">
|
||||||
|
<template #tab-catalogue>
|
||||||
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<header class="flex flex-col gap-2">
|
||||||
|
<h2 class="text-xl font-semibold text-base-content">Pièces créées</h2>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Liste globale des pièces enregistrées, quel que soit leur squelette d'origine.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="pieceRows"
|
||||||
|
:loading="loadingPieces"
|
||||||
|
:sort="table.sort.value"
|
||||||
|
:pagination="paginationState"
|
||||||
|
:column-filters="table.columnFilters.value"
|
||||||
|
:show-per-page="true"
|
||||||
|
empty-message="Aucune pièce n'a encore été créée."
|
||||||
|
no-results-message="Aucune pièce ne correspond à votre recherche."
|
||||||
|
@sort="table.handleSort"
|
||||||
|
@update:current-page="table.handlePageChange"
|
||||||
|
@update:per-page="table.handlePerPageChange"
|
||||||
|
@update:column-filters="table.handleColumnFiltersChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<label class="w-full sm:w-72">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
|
<input
|
||||||
|
v-model="table.searchTerm.value"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Nom ou référence…"
|
||||||
|
@input="table.debouncedSearch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-preview="{ row }">
|
||||||
|
<DocumentThumbnail
|
||||||
|
:document="resolvePrimaryDocument(row.piece)"
|
||||||
|
:alt="resolvePreviewAlt(row.piece)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
{{ row.piece.name || 'Pièce sans nom' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-reference="{ row }">
|
||||||
|
{{ row.piece.reference || '—' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-referenceAuto="{ row }">
|
||||||
|
{{ row.piece.referenceAuto || '—' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-description="{ row }">
|
||||||
|
<div v-if="row.piece.description" class="group relative">
|
||||||
|
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
|
||||||
|
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-sm group-hover:pointer-events-auto group-hover:visible">
|
||||||
|
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-suppliers="{ row }">
|
||||||
|
<div
|
||||||
|
v-if="row.suppliers.visible.length"
|
||||||
|
class="flex max-w-[14rem] flex-wrap items-center gap-1"
|
||||||
|
:title="row.suppliers.tooltip"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="supplier in row.suppliers.visible"
|
||||||
|
:key="supplier"
|
||||||
|
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ supplier }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="row.suppliers.overflow"
|
||||||
|
class="badge badge-outline badge-sm"
|
||||||
|
>
|
||||||
|
+{{ row.suppliers.overflow }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-typePiece="{ row }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="row.piece.typePiece?.id"
|
||||||
|
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ resolvePieceType(row.piece) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ resolvePieceType(row.piece) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-createdAt="{ row }">
|
||||||
|
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="navigateTo(`/piece/${row.piece.id}?edit=true`)"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
:disabled="loadingPieces"
|
||||||
|
@click="handleDeletePiece(row.piece)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/piece/${row.piece.id}`"
|
||||||
|
class="btn btn-primary btn-xs"
|
||||||
|
>
|
||||||
|
Détails
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-categories>
|
||||||
|
<ManagementView
|
||||||
|
category="PIECE"
|
||||||
|
heading="Catégories de pièce"
|
||||||
|
:hide-heading="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</EntityTabs>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
|
import ManagementView from '~/components/model-types/ManagementView.vue'
|
||||||
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
|
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||||
|
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
|
const activeTab = ref((route.query.tab as string) || 'catalogue')
|
||||||
|
watch(activeTab, (val) => {
|
||||||
|
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageTabs = computed(() => {
|
||||||
|
const t: Array<{ key: string; label: string }> = [
|
||||||
|
{ key: 'catalogue', label: 'Catalogue' },
|
||||||
|
]
|
||||||
|
if (canEdit.value) {
|
||||||
|
t.push({ key: 'categories', label: 'Catégories' })
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
|
||||||
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
|
|
||||||
|
const table = useDataTable(
|
||||||
|
{ fetchData: fetchPieces },
|
||||||
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typePiece'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence' },
|
||||||
|
{ key: 'referenceAuto', label: 'Réf. auto' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||||
|
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const piecesOnPage = computed(() => pieceRows.value.length)
|
||||||
|
const paginationState = table.pagination(total, piecesOnPage)
|
||||||
|
|
||||||
|
const piecesList = computed(() => {
|
||||||
|
return (pieces.value || []).map((piece) => {
|
||||||
|
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
|
||||||
|
return { ...piece, typePiece: typePiece || piece.typePiece || null }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieceRows = computed(() =>
|
||||||
|
piecesList.value.map(piece => ({
|
||||||
|
id: piece.id,
|
||||||
|
piece,
|
||||||
|
suppliers: buildPieceSuppliersDisplay(piece),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function fetchPieces() {
|
||||||
|
await loadPieces({
|
||||||
|
search: table.searchTerm.value,
|
||||||
|
page: table.currentPage.value,
|
||||||
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
|
orderBy: table.sortField.value,
|
||||||
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
|
typeName: table.columnFilters.value.typePiece || undefined,
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePieceType = (piece: Record<string, any>) => {
|
||||||
|
if (piece?.typePiece?.name) return piece.typePiece.name
|
||||||
|
if (piece?.typePieceLabel) return piece.typePieceLabel
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
|
||||||
|
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
||||||
|
|
||||||
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
|
const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||||
|
const pieceName = piece?.name || 'cette pièce'
|
||||||
|
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
|
||||||
|
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
|
||||||
|
if (!confirmed) return
|
||||||
|
await deletePiece(piece.id)
|
||||||
|
fetchPieces()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = formatFrenchDate
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([fetchPieces(), loadPieceTypes()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
278
frontend/app/pages/catalogues/produits.vue
Normal file
278
frontend/app/pages/catalogues/produits.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-10">
|
||||||
|
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Produits</h1>
|
||||||
|
<p class="text-sm text-base-content/70">Catalogue et catégories de produits.</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/product-category/new' : '/product/create'" class="btn btn-primary btn-sm md:btn-md">
|
||||||
|
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter un produit' }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Produits">
|
||||||
|
<template #tab-catalogue>
|
||||||
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="alert alert-error"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="font-semibold">Impossible de charger les produits</span>
|
||||||
|
<span class="text-sm">{{ errorMessage }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm ml-auto" @click="reload">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
v-else
|
||||||
|
:columns="columns"
|
||||||
|
:rows="productRows"
|
||||||
|
:loading="loading"
|
||||||
|
:sort="table.sort.value"
|
||||||
|
:pagination="paginationState"
|
||||||
|
:column-filters="table.columnFilters.value"
|
||||||
|
:show-per-page="true"
|
||||||
|
empty-message="Aucun produit n'a encore été enregistré."
|
||||||
|
no-results-message="Aucun produit ne correspond à votre recherche."
|
||||||
|
@sort="table.handleSort"
|
||||||
|
@update:current-page="table.handlePageChange"
|
||||||
|
@update:per-page="table.handlePerPageChange"
|
||||||
|
@update:column-filters="table.handleColumnFiltersChange"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<label class="w-full sm:w-72">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
|
<input
|
||||||
|
v-model="table.searchTerm.value"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Nom ou référence…"
|
||||||
|
@input="table.debouncedSearch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-preview="{ row }">
|
||||||
|
<DocumentThumbnail
|
||||||
|
:document="resolvePrimaryDocument(row.product, true)"
|
||||||
|
:alt="resolvePreviewAlt(row.product)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-medium">{{ row.product.name }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-reference="{ row }">
|
||||||
|
{{ row.product.reference || '—' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-typeProduct="{ row }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="row.product.typeProduct?.id"
|
||||||
|
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ row.product.typeProduct.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-suppliers="{ row }">
|
||||||
|
<div
|
||||||
|
v-if="row.suppliers.visible.length"
|
||||||
|
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
|
||||||
|
:title="row.suppliers.tooltip"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="supplier in row.suppliers.visible"
|
||||||
|
:key="supplier"
|
||||||
|
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ supplier }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="row.suppliers.overflow"
|
||||||
|
class="badge badge-outline badge-sm"
|
||||||
|
>
|
||||||
|
+{{ row.suppliers.overflow }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-sm text-base-content/50">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-price="{ row }">
|
||||||
|
{{ formatPrice(row.product.supplierPrice) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="navigateTo(`/product/${row.product.id}?edit=true`)"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
@click="confirmDelete(row.product)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/product/${row.product.id}`"
|
||||||
|
class="btn btn-primary btn-xs"
|
||||||
|
>
|
||||||
|
Détails
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-categories>
|
||||||
|
<ManagementView
|
||||||
|
category="PRODUCT"
|
||||||
|
heading="Catégories de produit"
|
||||||
|
:hide-heading="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</EntityTabs>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useHead } from '#imports'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
|
import ManagementView from '~/components/model-types/ManagementView.vue'
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
|
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||||
|
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
|
const activeTab = ref((route.query.tab as string) || 'catalogue')
|
||||||
|
watch(activeTab, (val) => {
|
||||||
|
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageTabs = computed(() => {
|
||||||
|
const t: Array<{ key: string; label: string }> = [
|
||||||
|
{ key: 'catalogue', label: 'Catalogue' },
|
||||||
|
]
|
||||||
|
if (canEdit.value) {
|
||||||
|
t.push({ key: 'categories', label: 'Catégories' })
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead(() => ({ title: 'Catalogue des produits' }))
|
||||||
|
|
||||||
|
const {
|
||||||
|
products,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
loadProducts,
|
||||||
|
deleteProduct,
|
||||||
|
} = useProducts()
|
||||||
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const table = useDataTable(
|
||||||
|
{ fetchData: fetchProducts },
|
||||||
|
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeProduct'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence' },
|
||||||
|
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||||
|
{ key: 'suppliers', label: 'Fournisseurs' },
|
||||||
|
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
|
||||||
|
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const productsOnPage = computed(() => productRows.value.length)
|
||||||
|
const paginationState = table.pagination(total, productsOnPage)
|
||||||
|
|
||||||
|
const normalizedProducts = computed(() => {
|
||||||
|
return (Array.isArray(products.value) ? products.value : []).map((product) => {
|
||||||
|
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
|
||||||
|
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const productRows = computed(() =>
|
||||||
|
normalizedProducts.value.map(product => ({
|
||||||
|
id: product.id,
|
||||||
|
product,
|
||||||
|
suppliers: buildProductSuppliersDisplay(product),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function fetchProducts() {
|
||||||
|
await loadProducts({
|
||||||
|
search: table.searchTerm.value,
|
||||||
|
page: table.currentPage.value,
|
||||||
|
itemsPerPage: table.itemsPerPage.value,
|
||||||
|
orderBy: table.sortField.value,
|
||||||
|
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||||
|
typeName: table.columnFilters.value.typeProduct || undefined,
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceFormatter = new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
currencyDisplay: 'narrowSymbol',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatPrice = (value: any) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '—'
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
|
||||||
|
buildSuppliersDisplay(resolveSupplierNames(product))
|
||||||
|
|
||||||
|
const reload = () => fetchProducts()
|
||||||
|
|
||||||
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
|
const confirmDelete = async (product: Record<string, any>) => {
|
||||||
|
const productName = product?.name || 'ce produit'
|
||||||
|
const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
|
||||||
|
const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
|
||||||
|
if (!confirmed) return
|
||||||
|
const result = await deleteProduct(product.id)
|
||||||
|
if (result.success) {
|
||||||
|
toast.showSuccess(`Produit "${productName}" supprimé`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([fetchProducts(), loadProductTypes()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="container mx-auto px-6 py-10 space-y-8">
|
|
||||||
<header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-base-content tracking-tight">Catalogue des composants</h1>
|
|
||||||
<p class="text-sm text-base-content/50 mt-1">
|
|
||||||
Consultez et gérez tous les composants existants.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md">
|
|
||||||
Ajouter un composant
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/component-category" class="btn btn-ghost btn-sm md:btn-md">
|
|
||||||
Gérer les catégories
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="card bg-base-100 shadow-sm">
|
|
||||||
<div class="card-body space-y-4">
|
|
||||||
<header class="flex flex-col gap-1">
|
|
||||||
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
|
|
||||||
<p class="text-sm text-base-content/50">
|
|
||||||
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
:columns="columns"
|
|
||||||
:rows="componentRows"
|
|
||||||
:loading="loadingComposants"
|
|
||||||
:sort="table.sort.value"
|
|
||||||
:pagination="paginationState"
|
|
||||||
:column-filters="table.columnFilters.value"
|
|
||||||
:show-per-page="true"
|
|
||||||
empty-message="Aucun composant n'a encore été créé."
|
|
||||||
no-results-message="Aucun composant ne correspond à votre recherche."
|
|
||||||
@sort="table.handleSort"
|
|
||||||
@update:current-page="table.handlePageChange"
|
|
||||||
@update:per-page="table.handlePerPageChange"
|
|
||||||
@update:column-filters="table.handleColumnFiltersChange"
|
|
||||||
>
|
|
||||||
<template #toolbar>
|
|
||||||
<label class="w-full sm:w-72">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
|
||||||
<input
|
|
||||||
v-model="table.searchTerm.value"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm w-full mt-1"
|
|
||||||
placeholder="Nom ou référence…"
|
|
||||||
@input="table.debouncedSearch"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-preview="{ row }">
|
|
||||||
<DocumentThumbnail
|
|
||||||
:document="resolvePrimaryDocument(row.component)"
|
|
||||||
:alt="resolvePreviewAlt(row.component)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-name="{ row }">
|
|
||||||
{{ row.component.name || 'Composant sans nom' }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-reference="{ row }">
|
|
||||||
{{ row.component.reference || '—' }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-description="{ row }">
|
|
||||||
<div v-if="row.component.description" class="group relative">
|
|
||||||
<span class="block cursor-help truncate">{{ row.component.description }}</span>
|
|
||||||
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
|
||||||
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span v-else>—</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-typeComposant="{ row }">
|
|
||||||
<NuxtLink
|
|
||||||
v-if="row.component.typeComposant?.id"
|
|
||||||
:to="`/component-category/${row.component.typeComposant.id}/edit`"
|
|
||||||
class="link link-hover link-primary"
|
|
||||||
>
|
|
||||||
{{ resolveComponentType(row.component) }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span v-else>{{ resolveComponentType(row.component) }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-createdAt="{ row }">
|
|
||||||
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs text-error"
|
|
||||||
:disabled="loadingComposants"
|
|
||||||
@click="handleDeleteComponent(row.component)"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<NuxtLink
|
|
||||||
:to="`/component/${row.component.id}`"
|
|
||||||
class="btn btn-primary btn-xs"
|
|
||||||
>
|
|
||||||
Détails
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
|
||||||
import { useComposants } from '~/composables/useComposants'
|
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
|
||||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
|
||||||
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
|
|
||||||
import { formatFrenchDate } from '~/utils/date'
|
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
|
||||||
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
|
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
|
||||||
|
|
||||||
const table = useDataTable(
|
|
||||||
{ fetchData: fetchComposants },
|
|
||||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
|
|
||||||
)
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
|
||||||
{ key: 'name', label: 'Nom', sortable: true },
|
|
||||||
{ key: 'reference', label: 'Référence' },
|
|
||||||
{ key: 'description', label: 'Description' },
|
|
||||||
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
|
|
||||||
{ key: 'createdAt', label: 'Date', sortable: true },
|
|
||||||
{ key: 'actions', label: 'Actions' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const composantsOnPage = computed(() => componentRows.value.length)
|
|
||||||
const paginationState = table.pagination(total, composantsOnPage)
|
|
||||||
|
|
||||||
// Enrich composants with full type data
|
|
||||||
const composantsList = computed(() => {
|
|
||||||
return (composants.value || []).map((composant) => {
|
|
||||||
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
|
||||||
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const componentRows = computed(() =>
|
|
||||||
composantsList.value.map(component => ({
|
|
||||||
id: component.id,
|
|
||||||
component,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function fetchComposants() {
|
|
||||||
await loadComposants({
|
|
||||||
search: table.searchTerm.value,
|
|
||||||
page: table.currentPage.value,
|
|
||||||
itemsPerPage: table.itemsPerPage.value,
|
|
||||||
orderBy: table.sortField.value,
|
|
||||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
|
||||||
typeName: table.columnFilters.value.typeComposant || undefined,
|
|
||||||
force: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveComponentType = (component: Record<string, any>) => {
|
|
||||||
if (component?.typeComposant?.name) return component.typeComposant.name
|
|
||||||
if (component?.typeComposantLabel) return component.typeComposantLabel
|
|
||||||
return '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
|
||||||
|
|
||||||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
|
||||||
const componentName = component?.name || 'ce composant'
|
|
||||||
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
|
|
||||||
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
|
|
||||||
if (!confirmed) return
|
|
||||||
await deleteComposant(component.id)
|
|
||||||
fetchComposants()
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = formatFrenchDate
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([fetchComposants(), loadComponentTypes()])
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<DocumentPreviewModal
|
|
||||||
:document="previewDocument"
|
|
||||||
:visible="previewVisible"
|
|
||||||
:documents="componentDocuments"
|
|
||||||
@close="closePreview"
|
|
||||||
/>
|
|
||||||
<DocumentEditModal
|
|
||||||
:visible="editModalVisible"
|
|
||||||
:document="editingDocument"
|
|
||||||
@close="editModalVisible = false"
|
|
||||||
@updated="handleDocumentUpdated"
|
|
||||||
/>
|
|
||||||
<main class="container mx-auto px-6 py-10">
|
|
||||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
|
|
||||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
|
||||||
<p class="text-sm text-base-content/70">Chargement du composant…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!component" class="max-w-xl mx-auto">
|
|
||||||
<div class="alert alert-error shadow-lg">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-lg">Composant introuvable</h2>
|
|
||||||
<p class="text-sm text-base-content/80">
|
|
||||||
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
|
||||||
<div class="card-body space-y-6">
|
|
||||||
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-semibold text-base-content">Modifier le composant</h1>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Mettez à jour les informations du composant et ses champs personnalisés.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Catégorie de composant</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
v-model="selectedTypeId"
|
|
||||||
class="select select-bordered select-sm md:select-md flex-1"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<option value="">Sélectionner une catégorie</option>
|
|
||||||
<option
|
|
||||||
v-for="type in componentTypeList"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ type.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="selectedTypeId"
|
|
||||||
:to="`/component-category/${selectedTypeId}/edit`"
|
|
||||||
class="btn btn-ghost btn-sm"
|
|
||||||
title="Voir la catégorie"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
|
||||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
|
||||||
</svg>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-base-content/60 mt-1">
|
|
||||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom du composant</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="editionForm.name"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Nom affiché dans le catalogue"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Description</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="editionForm.description"
|
|
||||||
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Description du composant (optionnel)"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Référence</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="editionForm.reference"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Référence interne ou fournisseur"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Fournisseur</span>
|
|
||||||
</label>
|
|
||||||
<ConstructeurSelect
|
|
||||||
v-model="editionForm.constructeurIds"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
|
||||||
:initial-options="component?.constructeurs || []"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConstructeurLinksTable
|
|
||||||
v-if="constructeurLinks.length"
|
|
||||||
v-model="constructeurLinks"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Prix indicatif (€)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="editionForm.prix"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Valeur indicatrice"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StructureSkeletonPreview
|
|
||||||
v-if="selectedType"
|
|
||||||
:structure="selectedTypeStructure"
|
|
||||||
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
|
||||||
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
|
||||||
variant="component"
|
|
||||||
show-empty-state
|
|
||||||
:resolve-piece-label="resolvePieceLabel"
|
|
||||||
:resolve-product-label="resolveProductLabel"
|
|
||||||
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
|
|
||||||
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
|
|
||||||
>
|
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">Sélections du squelette</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="pieceSlotEntries.length" class="space-y-2">
|
|
||||||
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
|
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="slot in pieceSlotEntries"
|
|
||||||
:key="`piece-slot-${slot.slotId}`"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<div class="flex-1">
|
|
||||||
<PieceSelect
|
|
||||||
:model-value="slot.selectedPieceId"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
:type-piece-id="slot.typePieceId"
|
|
||||||
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="w-20 shrink-0">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
:value="slot.quantity"
|
|
||||||
min="1"
|
|
||||||
class="input input-bordered input-sm w-full text-center"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
title="Quantité"
|
|
||||||
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="productSlotEntries.length" class="space-y-2">
|
|
||||||
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
|
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="slot in productSlotEntries"
|
|
||||||
:key="`product-slot-${slot.slotId}`"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
|
||||||
</label>
|
|
||||||
<ProductSelect
|
|
||||||
:model-value="slot.selectedProductId"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
:type-product-id="slot.typeProductId"
|
|
||||||
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
|
|
||||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="slot in subcomponentSlotEntries"
|
|
||||||
:key="`sub-slot-${slot.slotId}`"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
|
||||||
</label>
|
|
||||||
<ComposantSelect
|
|
||||||
:model-value="slot.selectedComponentId"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
:type-composant-id="slot.typeComposantId"
|
|
||||||
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Mettez à jour les valeurs propres à ce composant.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Gérez les documents associés à ce composant.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span v-if="selectedFiles.length" class="badge badge-outline">
|
|
||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
|
||||||
<DocumentUpload
|
|
||||||
v-model="selectedFiles"
|
|
||||||
title="Déposer vos fichiers"
|
|
||||||
subtitle="Formats acceptés : PDF, images, documents…"
|
|
||||||
@files-added="handleFilesAdded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Téléversement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Chargement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<DocumentListInline
|
|
||||||
v-else
|
|
||||||
:documents="componentDocuments"
|
|
||||||
:can-delete="canEdit"
|
|
||||||
:can-edit="true"
|
|
||||||
:delete-disabled="uploadingDocuments"
|
|
||||||
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
|
||||||
@preview="openPreview"
|
|
||||||
@edit="openEditModal"
|
|
||||||
@delete="removeDocument"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EntityHistorySection
|
|
||||||
:entries="history"
|
|
||||||
:loading="historyLoading"
|
|
||||||
:error="historyError"
|
|
||||||
:field-labels="historyFieldLabels"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EntityVersionList
|
|
||||||
entity-type="composant"
|
|
||||||
:entity-id="String(route.params.id)"
|
|
||||||
:field-labels="historyFieldLabels"
|
|
||||||
:refresh-key="versionRefreshKey"
|
|
||||||
@restored="fetchComponent()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
|
||||||
Annuler
|
|
||||||
</NuxtLink>
|
|
||||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="async () => { await submitEdition(); versionRefreshKey++ }">
|
|
||||||
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
|
|
||||||
Enregistrer les modifications
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comments -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<CommentSection
|
|
||||||
entity-type="composant"
|
|
||||||
:entity-id="String(route.params.id)"
|
|
||||||
:entity-name="component?.name"
|
|
||||||
show-resolved
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useRoute } from '#imports'
|
|
||||||
import { useComponentEdit } from '~/composables/useComponentEdit'
|
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const { updateDocument } = useDocuments()
|
|
||||||
const { getConstructeurById } = useConstructeurs()
|
|
||||||
const versionRefreshKey = ref(0)
|
|
||||||
|
|
||||||
const {
|
|
||||||
component,
|
|
||||||
loading,
|
|
||||||
saving,
|
|
||||||
selectedFiles,
|
|
||||||
uploadingDocuments,
|
|
||||||
loadingDocuments,
|
|
||||||
componentDocuments,
|
|
||||||
previewDocument,
|
|
||||||
previewVisible,
|
|
||||||
selectedTypeId,
|
|
||||||
editionForm,
|
|
||||||
constructeurLinks,
|
|
||||||
constructeurIdsFromForm,
|
|
||||||
customFieldInputs,
|
|
||||||
historyFieldLabels,
|
|
||||||
canEdit,
|
|
||||||
canSubmit,
|
|
||||||
componentTypeList,
|
|
||||||
selectedType,
|
|
||||||
selectedTypeStructure,
|
|
||||||
structureSelections,
|
|
||||||
pieceSlotEntries,
|
|
||||||
productSlotEntries,
|
|
||||||
subcomponentSlotEntries,
|
|
||||||
history,
|
|
||||||
historyLoading,
|
|
||||||
historyError,
|
|
||||||
openPreview,
|
|
||||||
closePreview,
|
|
||||||
removeDocument,
|
|
||||||
handleFilesAdded,
|
|
||||||
submitEdition,
|
|
||||||
setSlotQuantity,
|
|
||||||
setPieceSlotSelection,
|
|
||||||
setProductSlotSelection,
|
|
||||||
setSubcomponentSlotSelection,
|
|
||||||
resolvePieceLabel,
|
|
||||||
resolveProductLabel,
|
|
||||||
resolveSubcomponentLabel,
|
|
||||||
formatStructurePreview,
|
|
||||||
fetchComponent,
|
|
||||||
} = useComponentEdit(String(route.params.id))
|
|
||||||
|
|
||||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
|
||||||
watch(
|
|
||||||
() => editionForm.constructeurIds,
|
|
||||||
(ids) => {
|
|
||||||
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
|
|
||||||
for (const id of ids) {
|
|
||||||
if (!currentIds.has(id)) {
|
|
||||||
const resolved = getConstructeurById(id)
|
|
||||||
constructeurLinks.value.push({
|
|
||||||
constructeurId: id,
|
|
||||||
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
|
|
||||||
supplierReference: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove links whose ID was removed from the select
|
|
||||||
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const editingDocument = ref<any | null>(null)
|
|
||||||
const editModalVisible = ref(false)
|
|
||||||
|
|
||||||
const openEditModal = (doc: any) => {
|
|
||||||
editingDocument.value = doc
|
|
||||||
editModalVisible.value = true
|
|
||||||
}
|
|
||||||
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
|
|
||||||
if (!editingDocument.value?.id) return
|
|
||||||
const result = await updateDocument(editingDocument.value.id, data)
|
|
||||||
if (result.success) {
|
|
||||||
const idx = componentDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
|
|
||||||
if (idx !== -1) {
|
|
||||||
componentDocuments.value[idx] = { ...componentDocuments.value[idx], ...data }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editModalVisible.value = false
|
|
||||||
editingDocument.value = null
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -18,19 +18,13 @@
|
|||||||
<p class="text-sm text-base-content/70">Chargement du composant…</p>
|
<p class="text-sm text-base-content/70">Chargement du composant…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!component" class="max-w-xl mx-auto">
|
<EmptyState
|
||||||
<div class="alert alert-error shadow-lg">
|
v-else-if="!component"
|
||||||
<div>
|
title="Composant introuvable"
|
||||||
<h2 class="font-semibold text-lg">Composant introuvable</h2>
|
description="Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé."
|
||||||
<p class="text-sm text-base-content/80">
|
action-label="Retour au catalogue"
|
||||||
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
|
action-to="/catalogues/composants"
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
@@ -39,383 +33,435 @@
|
|||||||
:subtitle="isEditMode ? 'Ajustez les informations du composant et ses champs personnalisés.' : undefined"
|
:subtitle="isEditMode ? 'Ajustez les informations du composant et ses champs personnalisés.' : undefined"
|
||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
:can-edit="canEdit"
|
:can-edit="canEdit"
|
||||||
back-link="/component-catalog"
|
back-link="/catalogues/composants"
|
||||||
@toggle-edit="isEditMode = !isEditMode"
|
@toggle-edit="isEditMode = !isEditMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Catégorie (always shown) -->
|
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections composant">
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<template #tab-general>
|
||||||
<div class="form-control">
|
<div class="space-y-6">
|
||||||
<label class="label">
|
<!-- Catégorie (always shown) -->
|
||||||
<span class="label-text">Catégorie de composant</span>
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
</label>
|
<div class="form-control">
|
||||||
<template v-if="isEditMode">
|
<label class="label">
|
||||||
<div class="flex items-center gap-2">
|
<span class="label-text">Catégorie de composant</span>
|
||||||
<select
|
</label>
|
||||||
v-model="selectedTypeId"
|
<template v-if="isEditMode">
|
||||||
class="select select-bordered select-sm md:select-md flex-1"
|
<div class="flex items-center gap-2">
|
||||||
disabled
|
<select
|
||||||
>
|
v-model="selectedTypeId"
|
||||||
<option value="">Sélectionner une catégorie</option>
|
class="select select-bordered select-sm md:select-md flex-1"
|
||||||
<option
|
disabled
|
||||||
v-for="type in componentTypeList"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ type.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="selectedTypeId"
|
|
||||||
:to="`/component-category/${selectedTypeId}/edit`"
|
|
||||||
class="btn btn-ghost btn-sm"
|
|
||||||
title="Voir la catégorie"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
|
||||||
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
|
||||||
</svg>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-base-content/60 mt-1">
|
|
||||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
|
||||||
{{ selectedType?.name || '—' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nom (always shown) -->
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom du composant</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-if="isEditMode"
|
|
||||||
v-model="editionForm.name"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Nom affiché dans le catalogue"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
|
||||||
{{ component.name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description (if value or edit mode) -->
|
|
||||||
<div v-if="isEditMode || component.description" class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Description</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-if="isEditMode"
|
|
||||||
v-model="editionForm.description"
|
|
||||||
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Description du composant (optionnel)"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
|
|
||||||
{{ component.description }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Référence + Fournisseurs (if value or edit mode) -->
|
|
||||||
<div
|
|
||||||
v-if="isEditMode || component.reference || constructeurLinks.length"
|
|
||||||
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
|
||||||
>
|
|
||||||
<div v-if="isEditMode || component.reference" class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Référence</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-if="isEditMode"
|
|
||||||
v-model="editionForm.reference"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Référence interne ou fournisseur"
|
|
||||||
>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
|
||||||
{{ component.reference }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Fournisseur</span>
|
|
||||||
</label>
|
|
||||||
<ConstructeurSelect
|
|
||||||
v-if="isEditMode"
|
|
||||||
v-model="editionForm.constructeurIds"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
|
||||||
:initial-options="component?.constructeurs || []"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Constructeur links table -->
|
|
||||||
<ConstructeurLinksTable
|
|
||||||
v-if="isEditMode && constructeurLinks.length"
|
|
||||||
v-model="constructeurLinks"
|
|
||||||
/>
|
|
||||||
<ConstructeurLinksTable
|
|
||||||
v-else-if="!isEditMode && constructeurLinks.length"
|
|
||||||
:model-value="constructeurLinks"
|
|
||||||
:readonly="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Prix (if value or edit mode) -->
|
|
||||||
<div v-if="isEditMode || component.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Prix indicatif (€)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-if="isEditMode"
|
|
||||||
v-model="editionForm.prix"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Valeur indicatrice"
|
|
||||||
>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
|
||||||
{{ component.prix }} €
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Skeleton preview (edit mode only) -->
|
|
||||||
<StructureSkeletonPreview
|
|
||||||
v-if="isEditMode && selectedType"
|
|
||||||
:structure="selectedTypeStructure"
|
|
||||||
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
|
||||||
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
|
||||||
variant="component"
|
|
||||||
show-empty-state
|
|
||||||
:resolve-piece-label="resolvePieceLabel"
|
|
||||||
:resolve-product-label="resolveProductLabel"
|
|
||||||
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Skeleton slot selections -->
|
|
||||||
<div
|
|
||||||
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
|
|
||||||
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
|
|
||||||
>
|
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">{{ isEditMode ? 'Sélections du squelette' : 'Structure du composant' }}</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
{{ isEditMode ? 'Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.' : 'Pièces, produits et sous-composants associés à ce composant.' }}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="pieceSlotEntries.length" class="space-y-2">
|
|
||||||
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
|
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="slot in pieceSlotEntries"
|
|
||||||
:key="`piece-slot-${slot.slotId}`"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
|
||||||
</label>
|
|
||||||
<template v-if="isEditMode">
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<div class="flex-1">
|
|
||||||
<PieceSelect
|
|
||||||
:model-value="slot.selectedPieceId"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
:type-piece-id="slot.typePieceId"
|
|
||||||
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="w-20 shrink-0">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
:value="slot.quantity"
|
|
||||||
min="1"
|
|
||||||
class="input input-bordered input-sm w-full text-center"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
title="Quantité"
|
|
||||||
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
|
|
||||||
>
|
>
|
||||||
|
<option value="">Sélectionner une catégorie</option>
|
||||||
|
<option
|
||||||
|
v-for="type in componentTypeList"
|
||||||
|
:key="type.id"
|
||||||
|
:value="type.id"
|
||||||
|
>
|
||||||
|
{{ type.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="selectedTypeId"
|
||||||
|
:to="`/component-category/${selectedTypeId}/edit`"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
title="Voir la catégorie"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||||
|
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||||
|
</svg>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
|
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ selectedType?.name || '—' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nom (always shown) -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Nom du composant</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Nom affiché dans le catalogue"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ component.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description (if value or edit mode) -->
|
||||||
|
<div v-if="isEditMode || component.description" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Description</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Description du composant (optionnel)"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
|
||||||
|
{{ component.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Référence auto (read-only, shown only if computed) -->
|
||||||
|
<div v-if="component.referenceAuto" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Référence auto</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-sm font-medium text-base-content py-1 flex items-center gap-2">
|
||||||
|
<span class="font-mono font-semibold">{{ component.referenceAuto }}</span>
|
||||||
|
<span class="badge badge-sm badge-ghost">auto</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Référence + Fournisseurs (if value or edit mode) -->
|
||||||
|
<div
|
||||||
|
v-if="isEditMode || component.reference || constructeurLinks.length"
|
||||||
|
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div v-if="isEditMode || component.reference" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Référence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.reference"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Référence interne ou fournisseur"
|
||||||
|
>
|
||||||
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ component.reference }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Fournisseur</span>
|
||||||
|
</label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.constructeurIds"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
:initial-options="component?.constructeurs || []"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Constructeur links table -->
|
||||||
|
<ConstructeurLinksTable
|
||||||
|
v-if="isEditMode && constructeurLinks.length"
|
||||||
|
v-model="constructeurLinks"
|
||||||
|
/>
|
||||||
|
<ConstructeurLinksTable
|
||||||
|
v-else-if="!isEditMode && constructeurLinks.length"
|
||||||
|
:model-value="constructeurLinks"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Prix (if value or edit mode) -->
|
||||||
|
<div v-if="isEditMode || component.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Prix indicatif (€)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.prix"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Valeur indicatrice"
|
||||||
|
>
|
||||||
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ component.prix }} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UsedInSection entity-type="composants" :entity-id="component?.id ?? null" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-structure>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Skeleton preview (edit mode only) -->
|
||||||
|
<StructureSkeletonPreview
|
||||||
|
v-if="isEditMode && selectedType"
|
||||||
|
:structure="selectedTypeStructure"
|
||||||
|
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
||||||
|
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
||||||
|
variant="component"
|
||||||
|
show-empty-state
|
||||||
|
:resolve-piece-label="resolvePieceLabel"
|
||||||
|
:resolve-product-label="resolveProductLabel"
|
||||||
|
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Skeleton slot selections -->
|
||||||
|
<div
|
||||||
|
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
|
||||||
|
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
|
||||||
|
>
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h2 class="font-semibold text-base-content">{{ isEditMode ? 'Sélections du squelette' : 'Structure du composant' }}</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
{{ isEditMode ? 'Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.' : 'Pièces, produits et sous-composants associés à ce composant.' }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="pieceSlotEntries.length" class="space-y-2">
|
||||||
|
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="slot in pieceSlotEntries"
|
||||||
|
:key="`piece-slot-${slot.slotId}`"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
||||||
|
</label>
|
||||||
|
<template v-if="isEditMode">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<PieceSelect
|
||||||
|
:model-value="slot.selectedPieceId"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
:type-piece-id="slot.typePieceId"
|
||||||
|
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-20 shrink-0">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="slot.quantity"
|
||||||
|
min="1"
|
||||||
|
class="input input-bordered input-sm w-full text-center"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
title="Quantité"
|
||||||
|
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="text-sm font-medium py-1 px-2 rounded" :class="slot.isEmpty ? 'border border-error bg-error/10 text-error font-semibold' : 'text-base-content'">
|
||||||
|
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ slot.selectedPieceName }}
|
||||||
|
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md flex items-center gap-2" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
|
|
||||||
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ slot.selectedPieceName }}
|
|
||||||
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="productSlotEntries.length" class="space-y-2">
|
<div v-if="productSlotEntries.length" class="space-y-2">
|
||||||
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
|
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div
|
<div
|
||||||
v-for="slot in productSlotEntries"
|
v-for="slot in productSlotEntries"
|
||||||
:key="`product-slot-${slot.slotId}`"
|
:key="`product-slot-${slot.slotId}`"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
>
|
>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<template v-if="isEditMode">
|
<template v-if="isEditMode">
|
||||||
<ProductSelect
|
<ProductSelect
|
||||||
:model-value="slot.selectedProductId"
|
:model-value="slot.selectedProductId"
|
||||||
:disabled="!canEdit || saving"
|
:disabled="!canEdit || saving"
|
||||||
:type-product-id="slot.typeProductId"
|
:type-product-id="slot.typeProductId"
|
||||||
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
|
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
|
<div v-else class="text-sm font-medium py-1 px-2 rounded" :class="slot.isEmpty ? 'border border-error bg-error/10 text-error font-semibold' : 'text-base-content'">
|
||||||
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
||||||
<template v-else>{{ slot.selectedProductName }}</template>
|
<template v-else>{{ slot.selectedProductName }}</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
|
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
|
||||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div
|
<div
|
||||||
v-for="slot in subcomponentSlotEntries"
|
v-for="slot in subcomponentSlotEntries"
|
||||||
:key="`sub-slot-${slot.slotId}`"
|
:key="`sub-slot-${slot.slotId}`"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
>
|
>
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<template v-if="isEditMode">
|
<template v-if="isEditMode">
|
||||||
<ComposantSelect
|
<ComposantSelect
|
||||||
:model-value="slot.selectedComponentId"
|
:model-value="slot.selectedComponentId"
|
||||||
:disabled="!canEdit || saving"
|
:disabled="!canEdit || saving"
|
||||||
:type-composant-id="slot.typeComposantId"
|
:type-composant-id="slot.typeComposantId"
|
||||||
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
|
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
|
<div v-else class="text-sm font-medium py-1 px-2 rounded" :class="slot.isEmpty ? 'border border-error bg-error/10 text-error font-semibold' : 'text-base-content'">
|
||||||
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
||||||
<template v-else>{{ slot.selectedComponentName }}</template>
|
<template v-else>{{ slot.selectedComponentName }}</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom fields -->
|
|
||||||
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
|
||||||
<p v-if="isEditMode" class="text-xs text-base-content/70">
|
|
||||||
Mettez à jour les valeurs propres à ce composant.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<template v-if="isEditMode">
|
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="field in visibleCustomFields"
|
|
||||||
:key="field.customFieldValueId || field.id || field.name"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-sm">{{ field.name }}</span>
|
|
||||||
</label>
|
|
||||||
<div class="input input-bordered input-sm bg-base-200 flex items-center">
|
|
||||||
{{ field.value }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Documents -->
|
<template #tab-documents>
|
||||||
<div
|
<!-- Documents -->
|
||||||
v-if="isEditMode || componentDocuments.length > 0"
|
<div
|
||||||
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
v-if="isEditMode || componentDocuments.length > 0"
|
||||||
>
|
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
>
|
||||||
<div>
|
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
<div>
|
||||||
<p class="text-xs text-base-content/70">
|
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||||
{{ isEditMode ? 'Gérez les documents associés à ce composant.' : 'Documents associés à ce composant.' }}
|
<p class="text-xs text-base-content/70">
|
||||||
</p>
|
{{ isEditMode ? 'Gérez les documents associés à ce composant.' : 'Documents associés à ce composant.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
||||||
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<template v-if="isEditMode">
|
||||||
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
|
<DocumentUpload
|
||||||
|
v-model="selectedFiles"
|
||||||
|
title="Déposer vos fichiers"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents…"
|
||||||
|
@files-added="handleFilesAdded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Téléversement des documents en cours…
|
||||||
|
</p>
|
||||||
|
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Chargement des documents en cours…
|
||||||
|
</p>
|
||||||
|
<DocumentListInline
|
||||||
|
v-else
|
||||||
|
:documents="componentDocuments"
|
||||||
|
:can-delete="canEdit"
|
||||||
|
:can-edit="true"
|
||||||
|
:delete-disabled="uploadingDocuments"
|
||||||
|
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
||||||
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
|
@delete="removeDocument"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Chargement des documents en cours…
|
||||||
|
</p>
|
||||||
|
<DocumentListInline
|
||||||
|
v-else
|
||||||
|
:documents="componentDocuments"
|
||||||
|
:can-delete="false"
|
||||||
|
:can-edit="false"
|
||||||
|
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
||||||
|
@preview="openPreview"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
</template>
|
||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
|
||||||
</span>
|
<template #tab-custom-fields>
|
||||||
</header>
|
<!-- Custom fields -->
|
||||||
<template v-if="isEditMode">
|
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
<header class="space-y-1">
|
||||||
<DocumentUpload
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||||
v-model="selectedFiles"
|
<p v-if="isEditMode" class="text-xs text-base-content/70">
|
||||||
title="Déposer vos fichiers"
|
Mettez à jour les valeurs propres à ce composant.
|
||||||
subtitle="Formats acceptés : PDF, images, documents…"
|
</p>
|
||||||
@files-added="handleFilesAdded"
|
</header>
|
||||||
|
<template v-if="isEditMode">
|
||||||
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="field in visibleCustomFields"
|
||||||
|
:key="field.customFieldValueId || field.customFieldId || field.name"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-sm">{{ field.name }}</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ field.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-history>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<EntityHistorySection
|
||||||
|
:entries="history"
|
||||||
|
:loading="historyLoading"
|
||||||
|
:error="historyError"
|
||||||
|
:field-labels="historyFieldLabels"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EntityVersionList
|
||||||
|
entity-type="composant"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:field-labels="historyFieldLabels"
|
||||||
|
:refresh-key="versionRefreshKey"
|
||||||
|
@restored="fetchComponent()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="composant"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="component?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Téléversement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Chargement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<DocumentListInline
|
|
||||||
v-else
|
|
||||||
:documents="componentDocuments"
|
|
||||||
:can-delete="canEdit"
|
|
||||||
:can-edit="true"
|
|
||||||
:delete-disabled="uploadingDocuments"
|
|
||||||
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
|
||||||
@preview="openPreview"
|
|
||||||
@edit="openEditModal"
|
|
||||||
@delete="removeDocument"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
</EntityTabs>
|
||||||
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Chargement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<DocumentListInline
|
|
||||||
v-else
|
|
||||||
:documents="componentDocuments"
|
|
||||||
:can-delete="false"
|
|
||||||
:can-edit="false"
|
|
||||||
empty-text="Aucun document n'est associé à ce composant pour le moment."
|
|
||||||
@preview="openPreview"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EntityHistorySection
|
<!-- Save/Cancel buttons (outside tabs) -->
|
||||||
:entries="history"
|
|
||||||
:loading="historyLoading"
|
|
||||||
:error="historyError"
|
|
||||||
:field-labels="historyFieldLabels"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Save buttons (edit mode only) -->
|
|
||||||
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
|
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -425,16 +471,9 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
<!-- Comments -->
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
<div class="mt-4">
|
</p>
|
||||||
<CommentSection
|
|
||||||
entity-type="composant"
|
|
||||||
:entity-id="String(route.params.id)"
|
|
||||||
:entity-name="component?.name"
|
|
||||||
show-resolved
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -456,6 +495,12 @@ const { getConstructeurById } = useConstructeurs()
|
|||||||
const { updateDocument } = useDocuments()
|
const { updateDocument } = useDocuments()
|
||||||
|
|
||||||
const isEditMode = ref(false)
|
const isEditMode = ref(false)
|
||||||
|
const versionRefreshKey = ref(0)
|
||||||
|
|
||||||
|
const activeTab = ref((route.query.tab as string) || 'general')
|
||||||
|
watch(activeTab, (val) => {
|
||||||
|
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
component,
|
component,
|
||||||
@@ -472,6 +517,7 @@ const {
|
|||||||
constructeurLinks,
|
constructeurLinks,
|
||||||
constructeurIdsFromForm,
|
constructeurIdsFromForm,
|
||||||
customFieldInputs,
|
customFieldInputs,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
historyFieldLabels,
|
historyFieldLabels,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
componentTypeList,
|
componentTypeList,
|
||||||
@@ -499,11 +545,14 @@ const {
|
|||||||
formatStructurePreview,
|
formatStructurePreview,
|
||||||
} = useComponentEdit(String(route.params.id))
|
} = useComponentEdit(String(route.params.id))
|
||||||
|
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
const submitEdition = async () => {
|
const submitEdition = async () => {
|
||||||
await _submitEdition()
|
await _submitEdition()
|
||||||
if (!saving.value) {
|
if (!saving.value) {
|
||||||
await fetchComponent()
|
await fetchComponent()
|
||||||
isEditMode.value = false
|
isEditMode.value = false
|
||||||
|
versionRefreshKey.value++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,6 +586,14 @@ const visibleCustomFields = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const entityTabs = computed(() => [
|
||||||
|
{ key: 'general', label: 'Général' },
|
||||||
|
{ key: 'structure', label: 'Structure', count: pieceSlotEntries.value.length + productSlotEntries.value.length + subcomponentSlotEntries.value.length },
|
||||||
|
{ key: 'documents', label: 'Documents', count: componentDocuments.value.length },
|
||||||
|
{ key: 'custom-fields', label: 'Champs perso', count: visibleCustomFields.value.length },
|
||||||
|
{ key: 'history', label: 'Historique' },
|
||||||
|
])
|
||||||
|
|
||||||
const openEditModal = (doc: any) => {
|
const openEditModal = (doc: any) => {
|
||||||
editingDocument.value = doc
|
editingDocument.value = doc
|
||||||
editModalVisible.value = true
|
editModalVisible.value = true
|
||||||
|
|||||||
@@ -1,212 +1,243 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
<main class="container mx-auto px-6 py-10">
|
||||||
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
<div class="space-y-1">
|
|
||||||
<h1 class="text-3xl font-semibold text-base-content">Nouvel composant</h1>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<DetailHeader
|
||||||
<div class="form-control">
|
title="Nouveau composant"
|
||||||
<label class="label">
|
subtitle="Sélectionnez la catégorie cible puis complétez les informations du composant."
|
||||||
<span class="label-text">Catégorie de composant</span>
|
:is-edit-mode="false"
|
||||||
</label>
|
:can-edit="false"
|
||||||
<SearchSelect
|
back-link="/catalogues/composants"
|
||||||
v-model="selectedTypeId"
|
|
||||||
:options="componentTypeList"
|
|
||||||
:loading="loadingTypes"
|
|
||||||
size="sm"
|
|
||||||
placeholder="Rechercher une catégorie..."
|
|
||||||
empty-text="Aucune catégorie disponible"
|
|
||||||
:option-label="typeOptionLabel"
|
|
||||||
:option-description="typeOptionDescription"
|
|
||||||
:disabled="!canEdit || loadingTypes || submitting"
|
|
||||||
/>
|
|
||||||
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
|
||||||
Chargement des catégories…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom du composant</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="creationForm.name"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Nom affiché dans le catalogue"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Description</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="creationForm.description"
|
|
||||||
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Description du composant (optionnel)"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Référence</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="creationForm.reference"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Référence interne ou fournisseur"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Fournisseur</span>
|
|
||||||
</label>
|
|
||||||
<ConstructeurSelect
|
|
||||||
v-model="creationForm.constructeurIds"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConstructeurLinksTable
|
|
||||||
v-if="constructeurLinks.length"
|
|
||||||
v-model="constructeurLinks"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections composant">
|
||||||
<div class="form-control">
|
<template #tab-general>
|
||||||
<label class="label">
|
<div class="space-y-6">
|
||||||
<span class="label-text">Prix indicatif (€)</span>
|
<!-- Catégorie -->
|
||||||
</label>
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<input
|
<div class="form-control">
|
||||||
v-model="creationForm.prix"
|
<label class="label">
|
||||||
type="number"
|
<span class="label-text">Catégorie de composant</span>
|
||||||
step="0.01"
|
</label>
|
||||||
min="0"
|
<SearchSelect
|
||||||
class="input input-bordered input-sm md:input-md"
|
v-model="selectedTypeId"
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
:options="componentTypeList"
|
||||||
placeholder="Valeur indicatrice"
|
:loading="loadingTypes"
|
||||||
>
|
size="sm"
|
||||||
</div>
|
placeholder="Rechercher une catégorie..."
|
||||||
</div>
|
empty-text="Aucune catégorie disponible"
|
||||||
|
:option-label="typeOptionLabel"
|
||||||
|
:option-description="typeOptionDescription"
|
||||||
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
|
/>
|
||||||
|
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
||||||
|
Chargement des catégories…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<StructureSkeletonPreview
|
<!-- Nom -->
|
||||||
v-if="selectedType"
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
:structure="selectedTypeStructure"
|
<div class="form-control">
|
||||||
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
<label class="label">
|
||||||
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
<span class="label-text">Nom du composant</span>
|
||||||
variant="component"
|
</label>
|
||||||
:resolve-piece-label="resolvePieceLabel"
|
<input
|
||||||
:resolve-product-label="resolveProductLabel"
|
v-model="creationForm.name"
|
||||||
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
type="text"
|
||||||
/>
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Nom affiché dans le catalogue"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<!-- Description -->
|
||||||
v-if="structureHasRequirements"
|
<div class="form-control">
|
||||||
class="space-y-4 rounded-lg border border-primary/30 bg-primary/5 p-4"
|
<label class="label">
|
||||||
>
|
<span class="label-text">Description</span>
|
||||||
<div class="flex items-start justify-between gap-4">
|
</label>
|
||||||
<div>
|
<textarea
|
||||||
<h2 class="font-semibold text-base-content">
|
v-model="creationForm.description"
|
||||||
Sélection des éléments du squelette
|
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
||||||
</h2>
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
<p class="text-xs text-base-content/70">
|
placeholder="Description du composant (optionnel)"
|
||||||
Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Référence + Fournisseurs -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Référence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="creationForm.reference"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Référence interne ou fournisseur"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Fournisseur</span>
|
||||||
|
</label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
v-model="creationForm.constructeurIds"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConstructeurLinksTable
|
||||||
|
v-if="constructeurLinks.length"
|
||||||
|
v-model="constructeurLinks"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Prix -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Prix indicatif (€)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="creationForm.prix"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Valeur indicatrice"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-structure>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<StructureSkeletonPreview
|
||||||
|
v-if="selectedType"
|
||||||
|
:structure="selectedTypeStructure"
|
||||||
|
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
|
||||||
|
:preview-badge="formatStructurePreview(selectedTypeStructure)"
|
||||||
|
variant="component"
|
||||||
|
show-empty-state
|
||||||
|
:resolve-piece-label="resolvePieceLabel"
|
||||||
|
:resolve-product-label="resolveProductLabel"
|
||||||
|
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="structureHasRequirements"
|
||||||
|
class="space-y-4 rounded-lg border border-primary/30 bg-primary/5 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">
|
||||||
|
Sélection des éléments du squelette
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="structureSelectionsComplete ? 'badge-success' : structureDataLoading ? 'badge-info' : 'badge-warning'"
|
||||||
|
>
|
||||||
|
{{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="structureDataLoading"
|
||||||
|
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
|
||||||
|
>
|
||||||
|
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
Chargement du catalogue de pièces, produits et composants…
|
||||||
|
</div>
|
||||||
|
<ComponentStructureAssignmentNode
|
||||||
|
v-else-if="structureAssignments"
|
||||||
|
:assignment="structureAssignments"
|
||||||
|
:pieces="availablePieces"
|
||||||
|
:products="availableProducts"
|
||||||
|
:components="availableComponents"
|
||||||
|
:pieces-loading="piecesLoading"
|
||||||
|
:products-loading="productsLoading"
|
||||||
|
:components-loading="componentsLoading"
|
||||||
|
:piece-type-label-map="pieceTypeLabelMap"
|
||||||
|
:product-type-label-map="productTypeLabelMap"
|
||||||
|
:component-type-label-map="componentTypeLabelMap"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-xs text-error">
|
||||||
|
Impossible de générer les emplacements définis par le squelette.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-if="!selectedType"
|
||||||
|
title="Aucune catégorie sélectionnée"
|
||||||
|
description="Sélectionnez une catégorie dans l'onglet Général pour voir la structure du squelette."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-documents>
|
||||||
|
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Ajoutez des documents (PDF, images, textes…) liés à ce composant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
||||||
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
||||||
|
<DocumentUpload
|
||||||
|
v-model="selectedDocuments"
|
||||||
|
title="Déposer vos fichiers"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Téléversement des documents en cours…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
</template>
|
||||||
class="badge"
|
|
||||||
:class="structureSelectionsComplete ? 'badge-success' : structureDataLoading ? 'badge-info' : 'badge-warning'"
|
|
||||||
>
|
|
||||||
{{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<template #tab-custom-fields>
|
||||||
v-if="structureDataLoading"
|
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
|
<header class="space-y-1">
|
||||||
>
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
<p class="text-xs text-base-content/70">
|
||||||
Chargement du catalogue de pièces, produits et composants…
|
Renseignez les valeurs propres à ce composant selon le squelette choisi.
|
||||||
</div>
|
</p>
|
||||||
<ComponentStructureAssignmentNode
|
</header>
|
||||||
v-else-if="structureAssignments"
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
:assignment="structureAssignments"
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
:pieces="availablePieces"
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
:products="availableProducts"
|
|
||||||
:components="availableComponents"
|
|
||||||
:pieces-loading="piecesLoading"
|
|
||||||
:products-loading="productsLoading"
|
|
||||||
:components-loading="componentsLoading"
|
|
||||||
:piece-type-label-map="pieceTypeLabelMap"
|
|
||||||
:product-type-label-map="productTypeLabelMap"
|
|
||||||
:component-type-label-map="componentTypeLabelMap"
|
|
||||||
/>
|
|
||||||
<p v-else class="text-xs text-error">
|
|
||||||
Impossible de générer les emplacements définis par le squelette.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Renseignez les valeurs propres à ce composant selon le squelette choisi.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Ajoutez des documents (PDF, images, textes…) liés à ce composant.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
<EmptyState
|
||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
v-else
|
||||||
</span>
|
title="Aucun champ personnalisé"
|
||||||
</header>
|
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
|
||||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
|
||||||
<DocumentUpload
|
|
||||||
v-model="selectedDocuments"
|
|
||||||
title="Déposer vos fichiers"
|
|
||||||
subtitle="Formats acceptés : PDF, images, documents…"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
</EntityTabs>
|
||||||
Téléversement des documents en cours…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Save/Cancel buttons -->
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
<NuxtLink to="/catalogues/composants" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
||||||
Annuler
|
Annuler
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
||||||
@@ -214,17 +245,23 @@
|
|||||||
Créer le composant
|
Créer le composant
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires avant de créer le composant.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const { getConstructeurById } = useConstructeurs()
|
const { getConstructeurById } = useConstructeurs()
|
||||||
|
|
||||||
|
const activeTab = ref('general')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedTypeId,
|
selectedTypeId,
|
||||||
submitting,
|
submitting,
|
||||||
@@ -259,8 +296,18 @@ const {
|
|||||||
resolveProductLabel,
|
resolveProductLabel,
|
||||||
resolveSubcomponentLabel,
|
resolveSubcomponentLabel,
|
||||||
submitCreation,
|
submitCreation,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
} = useComponentCreate()
|
} = useComponentCreate()
|
||||||
|
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
|
const entityTabs = computed(() => [
|
||||||
|
{ key: 'general', label: 'Général' },
|
||||||
|
{ key: 'structure', label: 'Structure' },
|
||||||
|
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
|
||||||
|
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
|
||||||
|
])
|
||||||
|
|
||||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
||||||
watch(
|
watch(
|
||||||
() => creationForm.constructeurIds,
|
() => creationForm.constructeurIds,
|
||||||
|
|||||||
@@ -48,6 +48,39 @@
|
|||||||
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-composantCount="{ row }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="stats[row.id]?.composantCount"
|
||||||
|
:to="`/catalogues/composants?constructeur=${row.id}`"
|
||||||
|
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
|
||||||
|
>
|
||||||
|
{{ stats[row.id].composantCount }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="text-base-content/30">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-pieceCount="{ row }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="stats[row.id]?.pieceCount"
|
||||||
|
:to="`/catalogues/pieces?constructeur=${row.id}`"
|
||||||
|
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
|
||||||
|
>
|
||||||
|
{{ stats[row.id].pieceCount }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="text-base-content/30">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-machineCount="{ row }">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="stats[row.id]?.machineCount"
|
||||||
|
:to="`/machines?constructeur=${row.id}`"
|
||||||
|
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
|
||||||
|
>
|
||||||
|
{{ stats[row.id].machineCount }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="text-base-content/30">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
|
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
|
||||||
@@ -91,7 +124,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||||
@@ -103,6 +136,7 @@ import { formatPhone } from '~/utils/formatters/phone'
|
|||||||
import { formatFrenchDate } from '~/utils/date'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
@@ -112,12 +146,16 @@ const columns = [
|
|||||||
{ key: 'email', label: 'Email', sortable: true },
|
{ key: 'email', label: 'Email', sortable: true },
|
||||||
{ key: 'phone', label: 'Téléphone', sortable: true },
|
{ key: 'phone', label: 'Téléphone', sortable: true },
|
||||||
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
||||||
|
{ key: 'composantCount', label: 'Composants', align: 'center' },
|
||||||
|
{ key: 'pieceCount', label: 'Pièces', align: 'center' },
|
||||||
|
{ key: 'machineCount', label: 'Machines', align: 'center' },
|
||||||
{ key: 'actions', label: 'Actions', align: 'right' },
|
{ key: 'actions', label: 'Actions', align: 'right' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
||||||
const sortDir = ref('asc')
|
const sortDir = ref('asc')
|
||||||
|
const stats = ref({})
|
||||||
|
|
||||||
const currentSort = computed(() => ({
|
const currentSort = computed(() => ({
|
||||||
field: sortKey.value,
|
field: sortKey.value,
|
||||||
@@ -236,5 +274,15 @@ const confirmDelete = async (constructeur) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => loadConstructeurs())
|
const loadStats = async () => {
|
||||||
|
const result = await api.get('/constructeurs/stats')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
stats.value = result.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadConstructeurs()
|
||||||
|
loadStats()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
1002
frontend/app/pages/doc.vue
Normal file
1002
frontend/app/pages/doc.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control md:w-64">
|
<div class="form-control md:w-48">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
|
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -58,6 +58,24 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="dateFrom"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-base-content/50">à</span>
|
||||||
|
<input
|
||||||
|
v-model="dateTo"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,34 +86,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else-if="filteredSites.length === 0" class="text-center py-16">
|
<EmptyState
|
||||||
<div class="max-w-sm mx-auto">
|
v-else-if="filteredSites.length === 0"
|
||||||
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
|
:icon="IconLucideFactory"
|
||||||
<IconLucideFactory
|
title="Aucune machine trouvée"
|
||||||
class="w-8 h-8 text-base-content/30"
|
description="Commencez par ajouter des sites et des machines."
|
||||||
aria-hidden="true"
|
class="py-16"
|
||||||
/>
|
>
|
||||||
</div>
|
<div v-if="canEdit" class="flex gap-2 justify-center">
|
||||||
<h3 class="text-lg font-semibold text-base-content mb-1">
|
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
|
||||||
Aucune machine trouvée
|
Ajouter un site
|
||||||
</h3>
|
</button>
|
||||||
<p class="text-sm text-base-content/50 mb-6">
|
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
|
||||||
Commencez par ajouter des sites et des machines.
|
Ajouter une machine
|
||||||
</p>
|
</button>
|
||||||
<div class="flex gap-2 justify-center">
|
|
||||||
<button v-if="canEdit" class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
|
|
||||||
Ajouter un site
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
class="btn btn-ghost btn-sm"
|
|
||||||
@click="showAddMachineModal = true"
|
|
||||||
>
|
|
||||||
Ajouter une machine
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</EmptyState>
|
||||||
|
|
||||||
<!-- Sites List -->
|
<!-- Sites List -->
|
||||||
<div v-else class="space-y-5">
|
<div v-else class="space-y-5">
|
||||||
@@ -141,13 +147,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<span
|
<NuxtLink
|
||||||
class="badge font-bold"
|
:to="`/machines?sites=${site.id}`"
|
||||||
|
class="badge font-bold hover:opacity-80 transition-opacity"
|
||||||
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
|
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
|
||||||
:class="!site.color ? 'badge-primary' : ''"
|
:class="!site.color ? 'badge-primary' : ''"
|
||||||
>
|
>
|
||||||
{{ site.machines?.length || 0 }}
|
{{ site.machines?.length || 0 }} machine{{ (site.machines?.length || 0) > 1 ? 's' : '' }}
|
||||||
</span>
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs btn-circle"
|
class="btn btn-ghost btn-xs btn-circle"
|
||||||
@click="toggleSiteCollapse(site.id)"
|
@click="toggleSiteCollapse(site.id)"
|
||||||
@@ -288,6 +295,8 @@ const showAddSiteModal = ref(false)
|
|||||||
const showAddMachineModal = ref(false)
|
const showAddMachineModal = ref(false)
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const selectedSiteFilter = ref('')
|
const selectedSiteFilter = ref('')
|
||||||
|
const dateFrom = ref('')
|
||||||
|
const dateTo = ref('')
|
||||||
const collapsedSites = ref([])
|
const collapsedSites = ref([])
|
||||||
const preselectedSiteId = ref('')
|
const preselectedSiteId = ref('')
|
||||||
|
|
||||||
@@ -338,6 +347,25 @@ const filteredSites = computed(() => {
|
|||||||
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
|
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtrer les machines par date de création
|
||||||
|
if (dateFrom.value || dateTo.value) {
|
||||||
|
const from = dateFrom.value ? new Date(dateFrom.value) : null
|
||||||
|
const to = dateTo.value ? new Date(dateTo.value) : null
|
||||||
|
if (from) from.setHours(0, 0, 0, 0)
|
||||||
|
if (to) to.setHours(23, 59, 59, 999)
|
||||||
|
|
||||||
|
filtered = filtered.map((site) => {
|
||||||
|
const filteredMachines = (site.machines || []).filter((machine) => {
|
||||||
|
if (!machine.createdAt) return false
|
||||||
|
const created = new Date(machine.createdAt)
|
||||||
|
if (from && created < from) return false
|
||||||
|
if (to && created > to) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return { ...site, machines: filteredMachines }
|
||||||
|
}).filter(site => site.machines.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
// Filtrer par terme de recherche
|
// Filtrer par terme de recherche
|
||||||
if (searchTerm.value) {
|
if (searchTerm.value) {
|
||||||
filtered = filtered.filter((site) => {
|
filtered = filtered.filter((site) => {
|
||||||
|
|||||||
@@ -17,123 +17,129 @@
|
|||||||
|
|
||||||
<!-- Header with actions -->
|
<!-- Header with actions -->
|
||||||
<MachineDetailHeader
|
<MachineDetailHeader
|
||||||
:title="machineViewTitle"
|
:title="d.machine.value.name"
|
||||||
|
:description="d.machine.value.description"
|
||||||
|
:site-name="d.machine.value.site?.name"
|
||||||
|
:site-color="d.machine.value.site?.color"
|
||||||
|
:reference="d.machine.value.reference"
|
||||||
:is-edit-mode="d.isEditMode.value"
|
:is-edit-mode="d.isEditMode.value"
|
||||||
@toggle-edit="d.toggleEditMode"
|
@toggle-edit="d.toggleEditMode"
|
||||||
@open-print="d.openPrintModal"
|
@open-print="d.openPrintModal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Debug info -->
|
<!-- Tabbed content -->
|
||||||
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
|
<EntityTabs v-model="activeTab" :tabs="machineTabs" aria-label="Sections machine">
|
||||||
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
|
<template #tab-general>
|
||||||
<p>Components count: {{ d.components.value.length }}</p>
|
<div class="space-y-8">
|
||||||
<p>Pieces count: {{ d.pieces.value.length }}</p>
|
<MachineInfoCard
|
||||||
</div>
|
ref="machineInfoCardRef"
|
||||||
|
:is-edit-mode="d.isEditMode.value"
|
||||||
<!-- Hero -->
|
:machine-name="d.machineName.value"
|
||||||
<PageHero
|
:machine-reference="d.machineReference.value"
|
||||||
:title="d.machine.value.name"
|
:machine-site-id="d.machineSiteId.value"
|
||||||
:subtitle="d.machine.value.description"
|
:machine-site-name="d.machine.value?.site?.name ?? ''"
|
||||||
min-height="min-h-[20vh]"
|
:sites="d.sites.value"
|
||||||
max-width="max-w-md"
|
:machine-constructeur-ids="d.machineConstructeurIds.value"
|
||||||
rounded
|
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
|
||||||
>
|
:has-machine-constructeur="d.hasMachineConstructeur.value"
|
||||||
<div class="flex justify-center gap-4">
|
:constructeur-links="d.constructeurLinks.value"
|
||||||
<div
|
:visible-custom-fields="d.visibleMachineCustomFields.value"
|
||||||
v-if="d.machine.value.site?.name"
|
:get-machine-field-id="d.getMachineFieldId"
|
||||||
class="badge badge-outline font-semibold"
|
:machine-id="machineId"
|
||||||
:style="d.machine.value.site?.color ? { borderColor: d.machine.value.site.color + '60', backgroundColor: d.machine.value.site.color + '25', color: d.machine.value.site.color } : {}"
|
:machine-custom-field-defs="d.machine.value?.customFields ?? []"
|
||||||
>
|
@update:machine-name="d.machineName.value = $event"
|
||||||
{{ d.machine.value.site?.name }}
|
@update:machine-reference="d.machineReference.value = $event"
|
||||||
|
@update:machine-site-id="d.machineSiteId.value = $event"
|
||||||
|
@update:constructeur-ids="d.handleMachineConstructeurChange"
|
||||||
|
@update:constructeur-links="d.constructeurLinks.value = $event"
|
||||||
|
@remove-constructeur-link="handleRemoveConstructeurLink"
|
||||||
|
@set-custom-field-value="d.setMachineCustomFieldValue"
|
||||||
|
@custom-fields-saved="() => { if (!isSavingMachine) { d.loadMachineData(); refreshVersions() } }"
|
||||||
|
/>
|
||||||
|
<MachineProductsCard
|
||||||
|
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
|
||||||
|
:products="d.machineDirectProducts.value"
|
||||||
|
:is-edit-mode="d.isEditMode.value"
|
||||||
|
@add-product="openAddModal('product')"
|
||||||
|
@remove-product="confirmRemoveProduct"
|
||||||
|
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'product', typeId)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="d.machine.value.reference" class="badge badge-outline">
|
</template>
|
||||||
{{ d.machine.value.reference }}
|
|
||||||
|
<template #tab-structure>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<MachineComponentsCard
|
||||||
|
v-if="d.isEditMode.value || d.components.value.length > 0"
|
||||||
|
:components="d.components.value"
|
||||||
|
:is-edit-mode="d.isEditMode.value"
|
||||||
|
:collapsed="d.componentsCollapsed.value"
|
||||||
|
:collapse-toggle-token="d.collapseToggleToken.value"
|
||||||
|
@toggle-collapse="d.toggleAllComponents"
|
||||||
|
@update-component="d.updateComponent"
|
||||||
|
@edit-piece="d.updatePieceFromComponent"
|
||||||
|
@custom-field-update="d.handleCustomFieldUpdate"
|
||||||
|
@add-component="openAddModal('component')"
|
||||||
|
@remove-component="confirmRemoveComponent"
|
||||||
|
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'component', typeId)"
|
||||||
|
/>
|
||||||
|
<MachinePiecesCard
|
||||||
|
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
|
||||||
|
:pieces="d.machinePieces.value"
|
||||||
|
:is-edit-mode="d.isEditMode.value"
|
||||||
|
:collapsed="d.piecesCollapsed.value"
|
||||||
|
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
|
||||||
|
@update-piece="d.updatePieceInfo"
|
||||||
|
@edit-piece="d.editPiece"
|
||||||
|
@custom-field-update="d.handleCustomFieldUpdate"
|
||||||
|
@add-piece="openAddModal('piece')"
|
||||||
|
@remove-piece="confirmRemovePiece"
|
||||||
|
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'piece', typeId)"
|
||||||
|
@toggle-collapse="d.toggleAllPieces"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</PageHero>
|
|
||||||
|
|
||||||
<!-- Machine Info Card -->
|
<template #tab-documents>
|
||||||
<MachineInfoCard
|
<MachineDocumentsCard
|
||||||
ref="machineInfoCardRef"
|
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
|
||||||
:is-edit-mode="d.isEditMode.value"
|
:documents="d.machineDocumentsList.value"
|
||||||
:machine-name="d.machineName.value"
|
:is-edit-mode="d.isEditMode.value"
|
||||||
:machine-reference="d.machineReference.value"
|
:uploading="d.machineDocumentsUploading.value"
|
||||||
:machine-site-id="d.machineSiteId.value"
|
:files="d.machineDocumentFiles.value"
|
||||||
:machine-site-name="d.machine.value?.site?.name ?? ''"
|
@update:files="d.machineDocumentFiles.value = $event"
|
||||||
:sites="d.sites.value"
|
@files-added="d.handleMachineFilesAdded"
|
||||||
:machine-constructeur-ids="d.machineConstructeurIds.value"
|
@preview="d.openPreview"
|
||||||
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
|
@download="d.downloadDocument"
|
||||||
:has-machine-constructeur="d.hasMachineConstructeur.value"
|
@remove="confirmRemoveDocument"
|
||||||
:constructeur-links="d.constructeurLinks.value"
|
/>
|
||||||
:visible-custom-fields="d.visibleMachineCustomFields.value"
|
</template>
|
||||||
:get-machine-field-id="d.getMachineFieldId"
|
|
||||||
:machine-id="machineId"
|
|
||||||
:machine-custom-field-defs="d.machine.value?.customFields ?? []"
|
|
||||||
@update:machine-name="d.machineName.value = $event"
|
|
||||||
@update:machine-reference="d.machineReference.value = $event"
|
|
||||||
@update:machine-site-id="d.machineSiteId.value = $event"
|
|
||||||
@update:constructeur-ids="d.handleMachineConstructeurChange"
|
|
||||||
@update:constructeur-links="d.constructeurLinks.value = $event"
|
|
||||||
@remove-constructeur-link="handleRemoveConstructeurLink"
|
|
||||||
@set-custom-field-value="d.setMachineCustomFieldValue"
|
|
||||||
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Documents -->
|
<template #tab-history>
|
||||||
<MachineDocumentsCard
|
<div class="space-y-8">
|
||||||
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
|
<EntityHistorySection
|
||||||
:documents="d.machineDocumentsList.value"
|
:entries="history"
|
||||||
:is-edit-mode="d.isEditMode.value"
|
:loading="historyLoading"
|
||||||
:uploading="d.machineDocumentsUploading.value"
|
:error="historyError"
|
||||||
:files="d.machineDocumentFiles.value"
|
:field-labels="historyFieldLabels"
|
||||||
@update:files="d.machineDocumentFiles.value = $event"
|
/>
|
||||||
@files-added="d.handleMachineFilesAdded"
|
<EntityVersionList
|
||||||
@preview="d.openPreview"
|
ref="versionListRef"
|
||||||
@download="d.downloadDocument"
|
entity-type="machine"
|
||||||
@remove="d.removeMachineDocument"
|
:entity-id="String(machineId)"
|
||||||
/>
|
:field-labels="historyFieldLabels"
|
||||||
|
:refresh-key="versionRefreshKey"
|
||||||
<!-- Produits associés -->
|
@restored="d.loadMachineData()"
|
||||||
<MachineProductsCard
|
/>
|
||||||
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
|
<CommentSection
|
||||||
:products="d.machineDirectProducts.value"
|
entity-type="machine"
|
||||||
:is-edit-mode="d.isEditMode.value"
|
:entity-id="String(machineId)"
|
||||||
@add-product="openAddModal('product')"
|
:entity-name="d.machine.value?.name"
|
||||||
@remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
|
show-resolved
|
||||||
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'product', typeId)"
|
/>
|
||||||
/>
|
</div>
|
||||||
|
</template>
|
||||||
<!-- Components Section -->
|
</EntityTabs>
|
||||||
<MachineComponentsCard
|
|
||||||
v-if="d.isEditMode.value || d.components.value.length > 0"
|
|
||||||
:components="d.components.value"
|
|
||||||
:is-edit-mode="d.isEditMode.value"
|
|
||||||
:collapsed="d.componentsCollapsed.value"
|
|
||||||
:collapse-toggle-token="d.collapseToggleToken.value"
|
|
||||||
@toggle-collapse="d.toggleAllComponents"
|
|
||||||
@update-component="d.updateComponent"
|
|
||||||
@edit-piece="d.updatePieceFromComponent"
|
|
||||||
@custom-field-update="d.handleCustomFieldUpdate"
|
|
||||||
@add-component="openAddModal('component')"
|
|
||||||
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
|
|
||||||
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'component', typeId)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Machine Pieces Section -->
|
|
||||||
<MachinePiecesCard
|
|
||||||
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
|
|
||||||
:pieces="d.machinePieces.value"
|
|
||||||
:is-edit-mode="d.isEditMode.value"
|
|
||||||
:collapsed="d.piecesCollapsed.value"
|
|
||||||
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
|
|
||||||
@update-piece="d.updatePieceInfo"
|
|
||||||
@edit-piece="d.editPiece"
|
|
||||||
@custom-field-update="d.handleCustomFieldUpdate"
|
|
||||||
@add-piece="openAddModal('piece')"
|
|
||||||
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
|
|
||||||
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'piece', typeId)"
|
|
||||||
@toggle-collapse="d.toggleAllPieces"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Add Entity Modal -->
|
<!-- Add Entity Modal -->
|
||||||
<AddEntityToMachineModal
|
<AddEntityToMachineModal
|
||||||
@@ -164,50 +170,17 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Historique -->
|
|
||||||
<EntityHistorySection
|
|
||||||
:entries="history"
|
|
||||||
:loading="historyLoading"
|
|
||||||
:error="historyError"
|
|
||||||
:field-labels="historyFieldLabels"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Versions -->
|
|
||||||
<EntityVersionList
|
|
||||||
ref="versionListRef"
|
|
||||||
entity-type="machine"
|
|
||||||
:entity-id="String(machineId)"
|
|
||||||
:field-labels="historyFieldLabels"
|
|
||||||
:refresh-key="versionRefreshKey"
|
|
||||||
@restored="d.loadMachineData()"
|
|
||||||
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Comments -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<CommentSection
|
|
||||||
entity-type="machine"
|
|
||||||
:entity-id="String(machineId)"
|
|
||||||
:entity-name="d.machine.value?.name"
|
|
||||||
show-resolved
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-else class="text-center py-12">
|
<EmptyState
|
||||||
<div class="max-w-md mx-auto">
|
v-else
|
||||||
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
|
:icon="IconLucideAlertTriangle"
|
||||||
<IconLucideAlertTriangle class="w-8 h-8 text-base-content/30" aria-hidden="true" />
|
title="Machine non trouvée"
|
||||||
</div>
|
:description="`La machine avec l'ID « ${machineId} » n'existe pas ou a été supprimée.`"
|
||||||
<h3 class="text-lg font-semibold text-base-content mb-1">Machine non trouvée</h3>
|
action-label="Retour aux machines"
|
||||||
<p class="text-sm text-base-content/50 mb-6">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p>
|
@action="$router.back()"
|
||||||
<button type="button" class="btn btn-primary" @click="$router.back()">
|
/>
|
||||||
Retour aux machines
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<MachinePrintSelectionModal
|
<MachinePrintSelectionModal
|
||||||
@@ -223,13 +196,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, watch, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useMachineDetailData } from '~/composables/useMachineDetailData'
|
import { useMachineDetailData } from '~/composables/useMachineDetailData'
|
||||||
import { useEntityHistory } from '~/composables/useEntityHistory'
|
import { useEntityHistory } from '~/composables/useEntityHistory'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import PageHero from '~/components/PageHero.vue'
|
|
||||||
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
|
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
|
||||||
import MachineDetailHeader from '~/components/machine/MachineDetailHeader.vue'
|
import MachineDetailHeader from '~/components/machine/MachineDetailHeader.vue'
|
||||||
import MachineInfoCard from '~/components/machine/MachineInfoCard.vue'
|
import MachineInfoCard from '~/components/machine/MachineInfoCard.vue'
|
||||||
@@ -251,9 +223,24 @@ if (!machineId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const d = useMachineDetailData(machineId)
|
const d = useMachineDetailData(machineId)
|
||||||
const machineInfoCardRef = ref(null)
|
const machineInfoCardRef = ref<{ saveFieldDefinitions?: () => Promise<void> } | null>(null)
|
||||||
const versionRefreshKey = ref(0)
|
const versionRefreshKey = ref(0)
|
||||||
const refreshVersions = () => { versionRefreshKey.value++ }
|
const refreshVersions = () => { versionRefreshKey.value++ }
|
||||||
|
const isSavingMachine = ref(false)
|
||||||
|
const { confirm: confirmDialog } = useConfirm()
|
||||||
|
|
||||||
|
const versionListRef = ref<InstanceType<typeof EntityVersionList> | null>(null)
|
||||||
|
const activeTab = ref((route.query.tab as string) || 'general')
|
||||||
|
watch(activeTab, (val) => {
|
||||||
|
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const machineTabs = computed(() => [
|
||||||
|
{ key: 'general', label: 'Général' },
|
||||||
|
{ key: 'structure', label: 'Structure', count: d.components.value.length + d.machinePieces.value.length },
|
||||||
|
{ key: 'documents', label: 'Documents', count: d.machineDocumentsList.value.length },
|
||||||
|
{ key: 'history', label: 'Historique' },
|
||||||
|
])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
history,
|
history,
|
||||||
@@ -321,25 +308,49 @@ const handleAddEntity = async (payload) => {
|
|||||||
refreshVersions()
|
refreshVersions()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFillEntity = (linkId, entityKind, modelTypeId) => {
|
const handleFillEntity = (linkId: string, entityKind: string, modelTypeId: string) => {
|
||||||
fillLinkId.value = linkId
|
fillLinkId.value = linkId
|
||||||
fillTypeId.value = modelTypeId
|
fillTypeId.value = modelTypeId
|
||||||
addModalKind.value = entityKind
|
addModalKind.value = entityKind
|
||||||
addModalOpen.value = true
|
addModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const machineViewTitle = computed(() => {
|
|
||||||
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitMachineEdition = async () => {
|
const submitMachineEdition = async () => {
|
||||||
if (machineInfoCardRef.value?.saveFieldDefinitions) {
|
isSavingMachine.value = true
|
||||||
await machineInfoCardRef.value.saveFieldDefinitions()
|
try {
|
||||||
|
if (machineInfoCardRef.value?.saveFieldDefinitions) {
|
||||||
|
await machineInfoCardRef.value.saveFieldDefinitions()
|
||||||
|
}
|
||||||
|
await d.submitEdition()
|
||||||
|
refreshVersions()
|
||||||
|
} finally {
|
||||||
|
isSavingMachine.value = false
|
||||||
}
|
}
|
||||||
await d.submitEdition()
|
}
|
||||||
|
|
||||||
|
const confirmRemoveProduct = async (id: string) => {
|
||||||
|
if (!await confirmDialog({ title: 'Retirer ce produit ?', message: 'Le produit sera dissocié de la machine.', confirmText: 'Retirer', dangerous: true })) return
|
||||||
|
await d.removeProductLink(id)
|
||||||
refreshVersions()
|
refreshVersions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmRemoveComponent = async (id: string) => {
|
||||||
|
if (!await confirmDialog({ title: 'Retirer ce composant ?', message: 'Le composant sera dissocié de la machine.', confirmText: 'Retirer', dangerous: true })) return
|
||||||
|
await d.removeComponentLink(id)
|
||||||
|
refreshVersions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmRemovePiece = async (id: string) => {
|
||||||
|
if (!await confirmDialog({ title: 'Retirer cette pièce ?', message: 'La pièce sera dissociée de la machine.', confirmText: 'Retirer', dangerous: true })) return
|
||||||
|
await d.removePieceLink(id)
|
||||||
|
refreshVersions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmRemoveDocument = async (id: string) => {
|
||||||
|
if (!await confirmDialog({ title: 'Supprimer ce document ?', message: 'Le fichier sera supprimé définitivement.', confirmText: 'Supprimer', dangerous: true })) return
|
||||||
|
await d.removeMachineDocument(id)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
d.loadMachineData()
|
d.loadMachineData()
|
||||||
d.loadInitialData()
|
d.loadInitialData()
|
||||||
|
|||||||
@@ -5,114 +5,93 @@
|
|||||||
<h2 class="text-2xl font-bold">
|
<h2 class="text-2xl font-bold">
|
||||||
Parc Machines
|
Parc Machines
|
||||||
</h2>
|
</h2>
|
||||||
<NuxtLink to="/machines/new" class="btn btn-primary">
|
<NuxtLink v-if="canEdit" to="/machines/new" class="btn btn-primary">
|
||||||
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
Ajouter une machine
|
Ajouter une machine
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-sm mb-6">
|
<div class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<DataTable
|
||||||
<div class="form-control">
|
:columns="columns"
|
||||||
<label class="label">
|
:rows="filteredMachines"
|
||||||
<span class="label-text">Sites</span>
|
:loading="loading"
|
||||||
</label>
|
:sort="currentSort"
|
||||||
<div class="flex flex-wrap gap-3">
|
:show-counter="true"
|
||||||
<label
|
empty-message="Aucune machine trouvée."
|
||||||
v-for="site in sites"
|
no-results-message="Aucune machine ne correspond à vos filtres."
|
||||||
:key="site.id"
|
@sort="handleSort"
|
||||||
class="flex items-center gap-2 cursor-pointer"
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<label class="w-full sm:w-72">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Nom ou référence..."
|
||||||
>
|
>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
:checked="selectedSites.has(site.id)"
|
|
||||||
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
|
|
||||||
>
|
|
||||||
<span class="text-sm">{{ site.name }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Recherche</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
<div class="flex flex-col">
|
||||||
type="text"
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Site</span>
|
||||||
placeholder="Rechercher par nom ou référence..."
|
<select v-model="selectedSiteId" class="select select-bordered select-sm mt-1">
|
||||||
class="input input-bordered"
|
<option value="">Tous les sites</option>
|
||||||
|
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||||
|
{{ site.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Date de création</span>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
v-model="dateFrom"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-base-content/50">à</span>
|
||||||
|
<input
|
||||||
|
v-model="dateTo"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-site="{ row }">
|
||||||
|
<span
|
||||||
|
v-if="row.site"
|
||||||
|
class="badge badge-sm font-bold"
|
||||||
|
:style="row.site.color ? { backgroundColor: row.site.color + '30', color: row.site.color, borderColor: row.site.color + '50' } : {}"
|
||||||
|
:class="!row.site.color ? 'badge-ghost' : ''"
|
||||||
>
|
>
|
||||||
</div>
|
{{ row.site.name }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span v-else class="text-base-content/30">—</span>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div v-if="loading" class="flex justify-center items-center py-12">
|
<template #cell-createdAt="{ row }">
|
||||||
<span class="loading loading-spinner loading-lg" />
|
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div v-else-if="filteredMachines.length === 0" class="text-center py-12">
|
<template #cell-actions="{ row }">
|
||||||
<div class="max-w-md mx-auto">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<IconLucideFactory class="w-16 h-16 mx-auto text-gray-400 mb-4" aria-hidden="true" />
|
<button v-if="canEdit" class="btn btn-ghost btn-xs" @click="editMachine(row)">
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
Modifier
|
||||||
Aucune machine trouvée
|
</button>
|
||||||
</h3>
|
<button v-if="canEdit" class="btn btn-ghost btn-xs text-error" @click="confirmDeleteMachine(row)">
|
||||||
<p class="text-gray-500 mb-4">
|
Supprimer
|
||||||
Commencez par ajouter votre première machine.
|
</button>
|
||||||
</p>
|
<NuxtLink :to="`/machine/${row.id}`" class="btn btn-primary btn-xs">
|
||||||
<NuxtLink to="/machines/new" class="btn btn-primary">
|
Détails
|
||||||
Ajouter une machine
|
</NuxtLink>
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<div
|
|
||||||
v-for="machine in filteredMachines"
|
|
||||||
:key="machine.id"
|
|
||||||
class="card site-card shadow-md hover:shadow-xl transition-shadow cursor-pointer overflow-hidden"
|
|
||||||
:style="{
|
|
||||||
borderTop: machine.site?.color ? `4px solid ${machine.site.color}` : '4px solid transparent',
|
|
||||||
background: machine.site?.color ? `linear-gradient(160deg, ${machine.site.color}30 0%, ${machine.site.color}08 40%, var(--color-base-100) 100%)` : undefined,
|
|
||||||
}"
|
|
||||||
@click="viewMachineDetails(machine)"
|
|
||||||
>
|
|
||||||
<div class="card-body flex flex-col">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h3 class="card-title text-lg">
|
|
||||||
{{ machine.name }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<IconLucideMapPin class="w-4 h-4" :style="{ color: machine.site?.color || '#3b82f6' }" aria-hidden="true" />
|
|
||||||
<span
|
|
||||||
class="font-bold text-sm px-2.5 py-1 rounded-lg text-base-content"
|
|
||||||
:style="machine.site?.color ? { backgroundColor: machine.site.color + '30', border: `1px solid ${machine.site.color}40` } : {}"
|
|
||||||
>{{ machine.site?.name || 'Site inconnu' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<div v-if="machine.reference" class="flex items-center gap-2">
|
</DataTable>
|
||||||
<IconLucideTag class="w-4 h-4 text-orange-500" aria-hidden="true" />
|
|
||||||
<span class="text-gray-600">{{ machine.reference }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto pt-3 flex items-center justify-end gap-2">
|
|
||||||
<button v-if="canEdit" class="btn btn-ghost btn-sm" @click.stop="editMachine(machine)">
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button v-if="canEdit" class="btn btn-ghost btn-sm text-error" @click.stop="confirmDeleteMachine(machine)">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-primary btn-sm">
|
|
||||||
Détails
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,16 +99,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
|
import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideFactory from '~icons/lucide/factory'
|
|
||||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
|
||||||
import IconLucideTag from '~icons/lucide/tag'
|
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
||||||
@@ -138,34 +117,46 @@ const toast = useToast()
|
|||||||
|
|
||||||
const urlState = useUrlState({
|
const urlState = useUrlState({
|
||||||
q: { default: '', debounce: 300 },
|
q: { default: '', debounce: 300 },
|
||||||
sites: { default: '' },
|
site: { default: '' },
|
||||||
|
from: { default: '' },
|
||||||
|
to: { default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchQuery = urlState.q
|
const searchQuery = urlState.q
|
||||||
const selectedSites = reactive(new Set())
|
const selectedSiteId = urlState.site
|
||||||
|
const dateFrom = urlState.from
|
||||||
|
const dateTo = urlState.to
|
||||||
|
|
||||||
// Sync URL → selectedSites on load and back/forward
|
const sortKey = usePersistedValue('machines-sort', 'name')
|
||||||
watch(urlState.sites, (val) => {
|
const sortDir = ref('asc')
|
||||||
selectedSites.clear()
|
|
||||||
if (val) {
|
|
||||||
for (const id of String(val).split(',')) {
|
|
||||||
if (id) selectedSites.add(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Sync selectedSites → URL
|
const currentSort = computed(() => ({
|
||||||
watch(() => [...selectedSites], (ids) => {
|
field: sortKey.value,
|
||||||
urlState.sites.value = ids.join(',')
|
direction: sortDir.value,
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
const handleSort = (sort) => {
|
||||||
|
sortKey.value = sort.field
|
||||||
|
sortDir.value = sort.direction
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence', sortable: true },
|
||||||
|
{ key: 'site', label: 'Site', sortable: true, sortKey: 'siteName' },
|
||||||
|
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions', align: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const formatDate = formatFrenchDate
|
||||||
|
|
||||||
// Enrichir les machines avec les objets site complets
|
|
||||||
const enrichedMachines = computed(() => {
|
const enrichedMachines = computed(() => {
|
||||||
return machines.value.map((machine) => {
|
return machines.value.map((machine) => {
|
||||||
const site = sites.value.find(s => s.id === machine.siteId)
|
const site = sites.value.find(s => s.id === machine.siteId)
|
||||||
return {
|
return {
|
||||||
...machine,
|
...machine,
|
||||||
site: site || null,
|
site: site || null,
|
||||||
|
siteName: site?.name || '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -173,29 +164,44 @@ const enrichedMachines = computed(() => {
|
|||||||
const filteredMachines = computed(() => {
|
const filteredMachines = computed(() => {
|
||||||
let filtered = enrichedMachines.value
|
let filtered = enrichedMachines.value
|
||||||
|
|
||||||
if (selectedSites.size > 0) {
|
if (selectedSiteId.value) {
|
||||||
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
|
filtered = filtered.filter(m => m.siteId === selectedSiteId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery.value.trim()) {
|
if (searchQuery.value.trim()) {
|
||||||
const term = searchQuery.value.trim().toLowerCase()
|
const term = searchQuery.value.trim().toLowerCase()
|
||||||
filtered = filtered.filter(machine =>
|
filtered = filtered.filter(m =>
|
||||||
machine.name?.toLowerCase().includes(term)
|
m.name?.toLowerCase().includes(term)
|
||||||
|| machine.reference?.toLowerCase().includes(term),
|
|| m.reference?.toLowerCase().includes(term),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered = [...filtered].sort((a, b) =>
|
if (dateFrom.value) {
|
||||||
(a.name || '').localeCompare(b.name || '', 'fr')
|
const from = new Date(dateFrom.value)
|
||||||
)
|
from.setHours(0, 0, 0, 0)
|
||||||
|
filtered = filtered.filter(m => m.createdAt && new Date(m.createdAt) >= from)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateTo.value) {
|
||||||
|
const to = new Date(dateTo.value)
|
||||||
|
to.setHours(23, 59, 59, 999)
|
||||||
|
filtered = filtered.filter(m => m.createdAt && new Date(m.createdAt) <= to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = sortKey.value
|
||||||
|
const dir = sortDir.value === 'desc' ? -1 : 1
|
||||||
|
filtered = [...filtered].sort((a, b) => {
|
||||||
|
if (key === 'createdAt') {
|
||||||
|
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
|
||||||
|
}
|
||||||
|
const valA = (key === 'siteName' ? a.siteName : a[key]) || ''
|
||||||
|
const valB = (key === 'siteName' ? b.siteName : b[key]) || ''
|
||||||
|
return dir * String(valA).localeCompare(String(valB), 'fr')
|
||||||
|
})
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewMachineDetails = (machine) => {
|
|
||||||
navigateTo(`/machine/${machine.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const editMachine = (machine) => {
|
const editMachine = (machine) => {
|
||||||
navigateTo(`/machine/${machine.id}?edit=true`)
|
navigateTo(`/machine/${machine.id}?edit=true`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,19 +18,13 @@
|
|||||||
<p class="text-sm text-base-content/70">Chargement de la pièce…</p>
|
<p class="text-sm text-base-content/70">Chargement de la pièce…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!piece" class="max-w-xl mx-auto">
|
<EmptyState
|
||||||
<div class="alert alert-error shadow-lg">
|
v-else-if="!piece"
|
||||||
<div>
|
title="Pièce introuvable"
|
||||||
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
|
description="Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée."
|
||||||
<p class="text-sm text-base-content/80">
|
action-label="Retour au catalogue"
|
||||||
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
|
action-to="/catalogues/pieces"
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
@@ -39,331 +33,385 @@
|
|||||||
:subtitle="isEditMode ? 'Ajustez les informations de la pièce et ses champs personnalisés.' : undefined"
|
:subtitle="isEditMode ? 'Ajustez les informations de la pièce et ses champs personnalisés.' : undefined"
|
||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
:can-edit="canEdit"
|
:can-edit="canEdit"
|
||||||
back-link="/pieces-catalog"
|
back-link="/catalogues/pieces"
|
||||||
@toggle-edit="isEditMode = !isEditMode"
|
@toggle-edit="isEditMode = !isEditMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Catégorie (always shown) -->
|
<EntityTabs
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
v-model="activeTab"
|
||||||
<div class="form-control">
|
:tabs="entityTabs"
|
||||||
<label class="label">
|
aria-label="Sections de la pièce"
|
||||||
<span class="label-text">Catégorie de pièce</span>
|
|
||||||
</label>
|
|
||||||
<template v-if="isEditMode">
|
|
||||||
<select
|
|
||||||
v-model="selectedTypeId"
|
|
||||||
class="select select-bordered select-sm md:select-md"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<option value="">Sélectionner une catégorie</option>
|
|
||||||
<option
|
|
||||||
v-for="type in pieceTypeList"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ type.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-base-content/60 mt-1">
|
|
||||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
|
||||||
{{ selectedType?.name || '—' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nom (always shown) -->
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom de la pièce</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-if="isEditMode"
|
|
||||||
v-model="editionForm.name"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Nom affiché dans le catalogue"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
|
||||||
{{ piece.name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description (if value or edit mode) -->
|
|
||||||
<div v-if="isEditMode || piece.description" class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Description</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-if="isEditMode"
|
|
||||||
v-model="editionForm.description"
|
|
||||||
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Description de la pièce (optionnel)"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
|
|
||||||
{{ piece.description }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Référence auto (read-only, shown only if computed) -->
|
|
||||||
<div v-if="piece.referenceAuto" class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Référence auto</span>
|
|
||||||
</label>
|
|
||||||
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
|
|
||||||
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
|
|
||||||
<span class="badge badge-sm badge-ghost">auto</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Référence + Fournisseurs (if value or edit mode) -->
|
|
||||||
<div
|
|
||||||
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
|
|
||||||
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
|
||||||
>
|
>
|
||||||
<div v-if="isEditMode || piece.reference" class="form-control">
|
<template #tab-general>
|
||||||
<label class="label">
|
<div class="space-y-6">
|
||||||
<span class="label-text">Référence</span>
|
<!-- Catégorie (always shown) -->
|
||||||
</label>
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<input
|
<div class="form-control">
|
||||||
v-if="isEditMode"
|
<label class="label">
|
||||||
v-model="editionForm.reference"
|
<span class="label-text">Catégorie de pièce</span>
|
||||||
type="text"
|
</label>
|
||||||
class="input input-bordered input-sm md:input-md"
|
<template v-if="isEditMode">
|
||||||
:disabled="!canEdit || saving"
|
<div class="flex items-center gap-2">
|
||||||
placeholder="Référence interne ou fournisseur"
|
<select
|
||||||
>
|
v-model="selectedTypeId"
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
class="select select-bordered select-sm md:select-md flex-1"
|
||||||
{{ piece.reference }}
|
disabled
|
||||||
</div>
|
>
|
||||||
</div>
|
<option value="">Sélectionner une catégorie</option>
|
||||||
|
<option
|
||||||
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
|
v-for="type in pieceTypeList"
|
||||||
<label class="label">
|
:key="type.id"
|
||||||
<span class="label-text">Fournisseur</span>
|
:value="type.id"
|
||||||
</label>
|
>
|
||||||
<template v-if="isEditMode">
|
{{ type.name }}
|
||||||
<ConstructeurSelect
|
</option>
|
||||||
v-model="editionForm.constructeurIds"
|
</select>
|
||||||
class="w-full"
|
<NuxtLink
|
||||||
:disabled="!canEdit || saving"
|
v-if="selectedTypeId"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
:to="`/piece-category/${selectedTypeId}/edit`"
|
||||||
:initial-options="piece?.constructeurs || []"
|
class="btn btn-ghost btn-sm"
|
||||||
/>
|
title="Voir la catégorie"
|
||||||
<ConstructeurLinksTable
|
>
|
||||||
v-model="constructeurLinks"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
class="mt-2"
|
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||||
@remove="handleConstructeurRemoved"
|
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||||
/>
|
</svg>
|
||||||
</template>
|
</NuxtLink>
|
||||||
<ConstructeurLinksTable
|
</div>
|
||||||
v-else
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
:model-value="constructeurLinks"
|
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
||||||
readonly
|
</p>
|
||||||
/>
|
</template>
|
||||||
</div>
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
</div>
|
{{ selectedType?.name || '—' }}
|
||||||
|
</p>
|
||||||
<!-- Prix (if value or edit mode) -->
|
</div>
|
||||||
<div v-if="isEditMode || piece.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Prix indicatif (€)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-if="isEditMode"
|
|
||||||
v-model="editionForm.prix"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Valeur indicatrice"
|
|
||||||
>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
|
||||||
{{ piece.prix }} €
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Product requirements -->
|
|
||||||
<div
|
|
||||||
v-if="structureProducts.length"
|
|
||||||
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
|
||||||
>
|
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">
|
|
||||||
Produits liés
|
|
||||||
</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<ul class="space-y-2 text-sm text-base-content/80">
|
|
||||||
<li
|
|
||||||
v-for="(description, index) in productRequirementDescriptions"
|
|
||||||
:key="`edit-requirement-${index}`"
|
|
||||||
class="flex items-start gap-2"
|
|
||||||
>
|
|
||||||
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
|
|
||||||
<span>{{ description }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div v-if="isEditMode" class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="entry in productRequirementEntries"
|
|
||||||
:key="entry.key"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelections[entry.index] }">
|
|
||||||
{{ entry.label }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<ProductSelect
|
|
||||||
:model-value="productSelections[entry.index] || null"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
:type-product-id="entry.typeProductId"
|
|
||||||
helper-text="Un produit valide est requis pour cette pièce."
|
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="(entry, index) in productRequirementEntries"
|
|
||||||
:key="entry.key"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelectionLabels[index] }">{{ entry.label }}</span>
|
|
||||||
</label>
|
|
||||||
<div class="input input-bordered input-sm md:input-md flex items-center" :class="productSelectionLabels[index] ? 'bg-base-200' : 'border-error bg-error/10 text-error font-semibold'">
|
|
||||||
<template v-if="!productSelectionLabels[index]">{{ entry.label }} — manquant</template>
|
|
||||||
<template v-else>{{ productSelectionLabels[index] }}</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Skeleton preview (edit mode only) -->
|
<!-- Nom (always shown) -->
|
||||||
<StructureSkeletonPreview
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
v-if="isEditMode && (selectedType || resolvedStructure)"
|
<div class="form-control">
|
||||||
:structure="resolvedStructure"
|
<label class="label">
|
||||||
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
|
<span class="label-text">Nom de la pièce</span>
|
||||||
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
|
</label>
|
||||||
variant="piece"
|
<input
|
||||||
/>
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Nom affiché dans le catalogue"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ piece.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Custom fields -->
|
<!-- Description (if value or edit mode) -->
|
||||||
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<div v-if="isEditMode || piece.description" class="form-control">
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
|
||||||
<p v-if="isEditMode" class="text-xs text-base-content/70">
|
|
||||||
Mettez à jour les valeurs propres à cette pièce.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<template v-if="isEditMode">
|
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="field in visibleCustomFields"
|
|
||||||
:key="field.customFieldValueId || field.id || field.name"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-sm">{{ field.name }}</span>
|
<span class="label-text">Description</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="input input-bordered input-sm bg-base-200 flex items-center">
|
<textarea
|
||||||
{{ field.value }}
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.description"
|
||||||
|
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Description de la pièce (optionnel)"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
|
||||||
|
{{ piece.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Référence auto (read-only, shown only if computed) -->
|
||||||
|
<div v-if="piece.referenceAuto" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Référence auto</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-sm font-medium text-base-content py-1 flex items-center gap-2">
|
||||||
|
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
|
||||||
|
<span class="badge badge-sm badge-ghost">auto</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Référence + Fournisseurs (if value or edit mode) -->
|
||||||
|
<div
|
||||||
|
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
|
||||||
|
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div v-if="isEditMode || piece.reference" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Référence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.reference"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Référence interne ou fournisseur"
|
||||||
|
>
|
||||||
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ piece.reference }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Fournisseur</span>
|
||||||
|
</label>
|
||||||
|
<template v-if="isEditMode">
|
||||||
|
<ConstructeurSelect
|
||||||
|
v-model="editionForm.constructeurIds"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
:initial-options="piece?.constructeurs || []"
|
||||||
|
/>
|
||||||
|
<ConstructeurLinksTable
|
||||||
|
v-model="constructeurLinks"
|
||||||
|
class="mt-2"
|
||||||
|
@remove="handleConstructeurRemoved"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<ConstructeurLinksTable
|
||||||
|
v-else
|
||||||
|
:model-value="constructeurLinks"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prix (if value or edit mode) -->
|
||||||
|
<div v-if="isEditMode || piece.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Prix indicatif (€)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.prix"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Valeur indicatrice"
|
||||||
|
>
|
||||||
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ piece.prix }} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skeleton preview (edit mode only) -->
|
||||||
|
<StructureSkeletonPreview
|
||||||
|
v-if="isEditMode && (selectedType || resolvedStructure)"
|
||||||
|
:structure="resolvedStructure"
|
||||||
|
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
|
||||||
|
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
|
||||||
|
variant="piece"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UsedInSection entity-type="pieces" :entity-id="piece?.id ?? null" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-products>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Product requirements -->
|
||||||
|
<div
|
||||||
|
v-if="structureProducts.length"
|
||||||
|
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||||
|
>
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h2 class="font-semibold text-base-content">
|
||||||
|
Produits liés
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<ul class="space-y-2 text-sm text-base-content/80">
|
||||||
|
<li
|
||||||
|
v-for="(description, index) in productRequirementDescriptions"
|
||||||
|
:key="`edit-requirement-${index}`"
|
||||||
|
class="flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
|
||||||
|
<span>{{ description }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="isEditMode" class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="entry in productRequirementEntries"
|
||||||
|
:key="entry.key"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelections[entry.index] }">
|
||||||
|
{{ entry.label }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<ProductSelect
|
||||||
|
:model-value="productSelections[entry.index] || null"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
:type-product-id="entry.typeProductId"
|
||||||
|
helper-text="Sélectionnez un produit (optionnel)."
|
||||||
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="(entry, index) in productRequirementEntries"
|
||||||
|
:key="entry.key"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelectionLabels[index] }">{{ entry.label }}</span>
|
||||||
|
</label>
|
||||||
|
<div class="text-sm font-medium py-1 px-2 rounded" :class="productSelectionLabels[index] ? 'text-base-content' : 'border border-error bg-error/10 text-error font-semibold'">
|
||||||
|
<template v-if="!productSelectionLabels[index]">{{ entry.label }} — manquant</template>
|
||||||
|
<template v-else>{{ productSelectionLabels[index] }}</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Documents -->
|
<template #tab-documents>
|
||||||
<div
|
<div class="space-y-6">
|
||||||
v-if="isEditMode || pieceDocuments.length > 0"
|
<!-- Documents -->
|
||||||
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
<div
|
||||||
>
|
v-if="isEditMode || pieceDocuments.length > 0"
|
||||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||||
<div>
|
>
|
||||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||||
<p class="text-xs text-base-content/70">
|
<div>
|
||||||
{{ isEditMode ? 'Gérez les documents associés à cette pièce.' : 'Documents associés à cette pièce.' }}
|
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||||
</p>
|
<p class="text-xs text-base-content/70">
|
||||||
|
{{ isEditMode ? 'Gérez les documents associés à cette pièce.' : 'Documents associés à cette pièce.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
||||||
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<template v-if="isEditMode">
|
||||||
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
|
<DocumentUpload
|
||||||
|
v-model="selectedFiles"
|
||||||
|
title="Déposer vos fichiers"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents…"
|
||||||
|
@files-added="handleFilesAdded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Téléversement des documents en cours…
|
||||||
|
</p>
|
||||||
|
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Chargement des documents en cours…
|
||||||
|
</p>
|
||||||
|
<DocumentListInline
|
||||||
|
v-else
|
||||||
|
:documents="pieceDocuments"
|
||||||
|
:can-delete="canEdit"
|
||||||
|
:can-edit="true"
|
||||||
|
:delete-disabled="uploadingDocuments"
|
||||||
|
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
||||||
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
|
@delete="removeDocument"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Chargement des documents en cours…
|
||||||
|
</p>
|
||||||
|
<DocumentListInline
|
||||||
|
v-else
|
||||||
|
:documents="pieceDocuments"
|
||||||
|
:can-delete="false"
|
||||||
|
:can-edit="false"
|
||||||
|
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
||||||
|
@preview="openPreview"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
</template>
|
||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
|
||||||
</span>
|
<template #tab-custom-fields>
|
||||||
</header>
|
<div class="space-y-6">
|
||||||
<template v-if="isEditMode">
|
<!-- Custom fields -->
|
||||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
<DocumentUpload
|
<header class="space-y-1">
|
||||||
v-model="selectedFiles"
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||||
title="Déposer vos fichiers"
|
<p v-if="isEditMode" class="text-xs text-base-content/70">
|
||||||
subtitle="Formats acceptés : PDF, images, documents…"
|
Mettez à jour les valeurs propres à cette pièce.
|
||||||
@files-added="handleFilesAdded"
|
</p>
|
||||||
|
</header>
|
||||||
|
<template v-if="isEditMode">
|
||||||
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="field in visibleCustomFields"
|
||||||
|
:key="field.customFieldValueId || field.customFieldId || field.name"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-sm">{{ field.name }}</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ field.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-history>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<EntityHistorySection
|
||||||
|
:entries="history"
|
||||||
|
:loading="historyLoading"
|
||||||
|
:error="historyError"
|
||||||
|
:field-labels="historyFieldLabels"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EntityVersionList
|
||||||
|
entity-type="piece"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:field-labels="historyFieldLabels"
|
||||||
|
:refresh-key="versionRefreshKey"
|
||||||
|
@restored="fetchPiece()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="piece"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="piece?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Téléversement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Chargement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<DocumentListInline
|
|
||||||
v-else
|
|
||||||
:documents="pieceDocuments"
|
|
||||||
:can-delete="canEdit"
|
|
||||||
:can-edit="true"
|
|
||||||
:delete-disabled="uploadingDocuments"
|
|
||||||
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
|
||||||
@preview="openPreview"
|
|
||||||
@edit="openEditModal"
|
|
||||||
@delete="removeDocument"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
</EntityTabs>
|
||||||
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Chargement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<DocumentListInline
|
|
||||||
v-else
|
|
||||||
:documents="pieceDocuments"
|
|
||||||
:can-delete="false"
|
|
||||||
:can-edit="false"
|
|
||||||
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
|
||||||
@preview="openPreview"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EntityHistorySection
|
|
||||||
:entries="history"
|
|
||||||
:loading="historyLoading"
|
|
||||||
:error="historyError"
|
|
||||||
:field-labels="historyFieldLabels"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EntityVersionList
|
|
||||||
entity-type="piece"
|
|
||||||
:entity-id="String(route.params.id)"
|
|
||||||
:field-labels="historyFieldLabels"
|
|
||||||
:refresh-key="versionRefreshKey"
|
|
||||||
@restored="fetchPiece()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Save buttons (edit mode only) -->
|
<!-- Save buttons (edit mode only) -->
|
||||||
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
@@ -375,16 +423,9 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
<!-- Comments -->
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
<div class="mt-4">
|
</p>
|
||||||
<CommentSection
|
|
||||||
entity-type="piece"
|
|
||||||
:entity-id="String(route.params.id)"
|
|
||||||
:entity-name="piece?.name"
|
|
||||||
show-resolved
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -405,6 +446,11 @@ const { updateDocument } = useDocuments()
|
|||||||
const isEditMode = ref(false)
|
const isEditMode = ref(false)
|
||||||
const versionRefreshKey = ref(0)
|
const versionRefreshKey = ref(0)
|
||||||
|
|
||||||
|
const activeTab = ref((route.query.tab as string) || 'general')
|
||||||
|
watch(activeTab, (val) => {
|
||||||
|
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
piece,
|
piece,
|
||||||
loading,
|
loading,
|
||||||
@@ -420,6 +466,7 @@ const {
|
|||||||
constructeurLinks,
|
constructeurLinks,
|
||||||
productSelections,
|
productSelections,
|
||||||
customFieldInputs,
|
customFieldInputs,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
pieceTypeList,
|
pieceTypeList,
|
||||||
selectedType,
|
selectedType,
|
||||||
resolvedStructure,
|
resolvedStructure,
|
||||||
@@ -441,6 +488,16 @@ const {
|
|||||||
formatPieceStructurePreview,
|
formatPieceStructurePreview,
|
||||||
} = usePieceEdit(String(route.params.id))
|
} = usePieceEdit(String(route.params.id))
|
||||||
|
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
|
const entityTabs = computed(() => [
|
||||||
|
{ key: 'general', label: 'Général' },
|
||||||
|
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
|
||||||
|
{ key: 'documents', label: 'Documents', count: pieceDocuments.value.length },
|
||||||
|
{ key: 'custom-fields', label: 'Champs perso', count: visibleCustomFields.value.length },
|
||||||
|
{ key: 'history', label: 'Historique' },
|
||||||
|
])
|
||||||
|
|
||||||
const submitEdition = async () => {
|
const submitEdition = async () => {
|
||||||
await _submitEdition()
|
await _submitEdition()
|
||||||
if (!saving.value) {
|
if (!saving.value) {
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="container mx-auto px-6 py-10 space-y-8">
|
|
||||||
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-semibold text-base-content">Catalogue des pièces</h1>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Consultez et gérez toutes les pièces existantes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<NuxtLink to="/pieces/create" class="btn btn-primary btn-sm md:btn-md">
|
|
||||||
Ajouter une pièce
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/piece-category" class="btn btn-outline btn-sm md:btn-md">
|
|
||||||
Gérer les catégories
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
|
||||||
<div class="card-body space-y-4">
|
|
||||||
<header class="flex flex-col gap-2">
|
|
||||||
<h2 class="text-xl font-semibold text-base-content">Pièces créées</h2>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Liste globale des pièces enregistrées, quel que soit leur squelette d'origine.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
:columns="columns"
|
|
||||||
:rows="pieceRows"
|
|
||||||
:loading="loadingPieces"
|
|
||||||
:sort="table.sort.value"
|
|
||||||
:pagination="paginationState"
|
|
||||||
:column-filters="table.columnFilters.value"
|
|
||||||
:show-per-page="true"
|
|
||||||
empty-message="Aucune pièce n'a encore été créée."
|
|
||||||
no-results-message="Aucune pièce ne correspond à votre recherche."
|
|
||||||
@sort="table.handleSort"
|
|
||||||
@update:current-page="table.handlePageChange"
|
|
||||||
@update:per-page="table.handlePerPageChange"
|
|
||||||
@update:column-filters="table.handleColumnFiltersChange"
|
|
||||||
>
|
|
||||||
<template #toolbar>
|
|
||||||
<label class="w-full sm:w-72">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
|
||||||
<input
|
|
||||||
v-model="table.searchTerm.value"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm w-full mt-1"
|
|
||||||
placeholder="Nom ou référence…"
|
|
||||||
@input="table.debouncedSearch"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-preview="{ row }">
|
|
||||||
<DocumentThumbnail
|
|
||||||
:document="resolvePrimaryDocument(row.piece)"
|
|
||||||
:alt="resolvePreviewAlt(row.piece)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-name="{ row }">
|
|
||||||
{{ row.piece.name || 'Pièce sans nom' }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-reference="{ row }">
|
|
||||||
{{ row.piece.reference || '—' }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-referenceAuto="{ row }">
|
|
||||||
{{ row.piece.referenceAuto || '—' }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-description="{ row }">
|
|
||||||
<div v-if="row.piece.description" class="group relative">
|
|
||||||
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
|
|
||||||
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-sm group-hover:pointer-events-auto group-hover:visible">
|
|
||||||
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span v-else>—</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-suppliers="{ row }">
|
|
||||||
<div
|
|
||||||
v-if="row.suppliers.visible.length"
|
|
||||||
class="flex max-w-[14rem] flex-wrap items-center gap-1"
|
|
||||||
:title="row.suppliers.tooltip"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-for="supplier in row.suppliers.visible"
|
|
||||||
:key="supplier"
|
|
||||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{{ supplier }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="row.suppliers.overflow"
|
|
||||||
class="badge badge-outline badge-sm"
|
|
||||||
>
|
|
||||||
+{{ row.suppliers.overflow }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span v-else>—</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-typePiece="{ row }">
|
|
||||||
<NuxtLink
|
|
||||||
v-if="row.piece.typePiece?.id"
|
|
||||||
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
|
|
||||||
class="link link-hover link-primary"
|
|
||||||
>
|
|
||||||
{{ resolvePieceType(row.piece) }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span v-else>{{ resolvePieceType(row.piece) }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-createdAt="{ row }">
|
|
||||||
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
@click="navigateTo(`/piece/${row.piece.id}?edit=true`)"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs text-error"
|
|
||||||
:disabled="loadingPieces"
|
|
||||||
@click="handleDeletePiece(row.piece)"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<NuxtLink
|
|
||||||
:to="`/piece/${row.piece.id}`"
|
|
||||||
class="btn btn-primary btn-xs"
|
|
||||||
>
|
|
||||||
Détails
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
|
||||||
import { usePieces } from '~/composables/usePieces'
|
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
|
||||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
|
||||||
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
|
||||||
import { formatFrenchDate } from '~/utils/date'
|
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
|
||||||
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
|
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
|
||||||
|
|
||||||
const table = useDataTable(
|
|
||||||
{ fetchData: fetchPieces },
|
|
||||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typePiece'] },
|
|
||||||
)
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
|
||||||
{ key: 'name', label: 'Nom', sortable: true },
|
|
||||||
{ key: 'reference', label: 'Référence' },
|
|
||||||
{ key: 'referenceAuto', label: 'Réf. auto' },
|
|
||||||
{ key: 'description', label: 'Description' },
|
|
||||||
{ key: 'suppliers', label: 'Fournisseurs' },
|
|
||||||
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
|
|
||||||
{ key: 'createdAt', label: 'Date', sortable: true },
|
|
||||||
{ key: 'actions', label: 'Actions' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const piecesOnPage = computed(() => pieceRows.value.length)
|
|
||||||
const paginationState = table.pagination(total, piecesOnPage)
|
|
||||||
|
|
||||||
// Enrich pieces with full type data
|
|
||||||
const piecesList = computed(() => {
|
|
||||||
return (pieces.value || []).map((piece) => {
|
|
||||||
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
|
|
||||||
return { ...piece, typePiece: typePiece || piece.typePiece || null }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieceRows = computed(() =>
|
|
||||||
piecesList.value.map(piece => ({
|
|
||||||
id: piece.id,
|
|
||||||
piece,
|
|
||||||
suppliers: buildPieceSuppliersDisplay(piece),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function fetchPieces() {
|
|
||||||
await loadPieces({
|
|
||||||
search: table.searchTerm.value,
|
|
||||||
page: table.currentPage.value,
|
|
||||||
itemsPerPage: table.itemsPerPage.value,
|
|
||||||
orderBy: table.sortField.value,
|
|
||||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
|
||||||
typeName: table.columnFilters.value.typePiece || undefined,
|
|
||||||
force: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvePieceType = (piece: Record<string, any>) => {
|
|
||||||
if (piece?.typePiece?.name) return piece.typePiece.name
|
|
||||||
if (piece?.typePieceLabel) return piece.typePieceLabel
|
|
||||||
return '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
|
|
||||||
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
|
||||||
|
|
||||||
const handleDeletePiece = async (piece: Record<string, any>) => {
|
|
||||||
const pieceName = piece?.name || 'cette pièce'
|
|
||||||
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
|
|
||||||
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
|
|
||||||
if (!confirmed) return
|
|
||||||
await deletePiece(piece.id)
|
|
||||||
fetchPieces()
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = formatFrenchDate
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([fetchPieces(), loadPieceTypes()])
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<DocumentPreviewModal
|
|
||||||
:document="previewDocument"
|
|
||||||
:visible="previewVisible"
|
|
||||||
:documents="pieceDocuments"
|
|
||||||
@close="closePreview"
|
|
||||||
/>
|
|
||||||
<DocumentEditModal
|
|
||||||
:visible="editModalVisible"
|
|
||||||
:document="editingDocument"
|
|
||||||
@close="editModalVisible = false"
|
|
||||||
@updated="handleDocumentUpdated"
|
|
||||||
/>
|
|
||||||
<main class="container mx-auto px-6 py-10">
|
|
||||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
|
|
||||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
|
||||||
<p class="text-sm text-base-content/70">Chargement de la pièce…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!piece" class="max-w-xl mx-auto">
|
|
||||||
<div class="alert alert-error shadow-lg">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
|
|
||||||
<p class="text-sm text-base-content/80">
|
|
||||||
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
|
||||||
<div class="card-body space-y-6">
|
|
||||||
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-semibold text-base-content">Modifier la pièce</h1>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Ajustez les informations de la pièce et ses champs personnalisés.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Catégorie de pièce</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
v-model="selectedTypeId"
|
|
||||||
class="select select-bordered select-sm md:select-md"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<option value="">Sélectionner une catégorie</option>
|
|
||||||
<option
|
|
||||||
v-for="type in pieceTypeList"
|
|
||||||
:key="type.id"
|
|
||||||
:value="type.id"
|
|
||||||
>
|
|
||||||
{{ type.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-base-content/60 mt-1">
|
|
||||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom de la pièce</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="editionForm.name"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Nom affiché dans le catalogue"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Description</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="editionForm.description"
|
|
||||||
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Description de la pièce (optionnel)"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Référence</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="editionForm.reference"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Référence interne ou fournisseur"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="piece?.referenceAuto" class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Référence auto</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
:value="piece.referenceAuto"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md bg-base-200"
|
|
||||||
disabled
|
|
||||||
title="Générée automatiquement à partir du type et des champs personnalisés"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Fournisseur</span>
|
|
||||||
</label>
|
|
||||||
<ConstructeurSelect
|
|
||||||
v-model="editionForm.constructeurIds"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
|
||||||
:initial-options="piece?.constructeurs || []"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Prix indicatif (€)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="editionForm.prix"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Valeur indicatrice"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="structureProducts.length"
|
|
||||||
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
|
||||||
>
|
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">
|
|
||||||
Produit requis par le squelette
|
|
||||||
</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<ul class="space-y-2 text-sm text-base-content/80">
|
|
||||||
<li
|
|
||||||
v-for="(description, index) in productRequirementDescriptions"
|
|
||||||
:key="`edit-requirement-${index}`"
|
|
||||||
class="flex items-start gap-2"
|
|
||||||
>
|
|
||||||
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
|
|
||||||
<span>{{ description }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="entry in productRequirementEntries"
|
|
||||||
:key="entry.key"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-xs font-medium">
|
|
||||||
{{ entry.label }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<ProductSelect
|
|
||||||
:model-value="productSelections[entry.index] || null"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
:type-product-id="entry.typeProductId"
|
|
||||||
helper-text="Un produit valide est requis pour cette pièce."
|
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<StructureSkeletonPreview
|
|
||||||
v-if="selectedType || resolvedStructure"
|
|
||||||
:structure="resolvedStructure"
|
|
||||||
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
|
|
||||||
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
|
|
||||||
variant="piece"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Mettez à jour les valeurs propres à cette pièce.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Gérez les documents associés à cette pièce.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span v-if="selectedFiles.length" class="badge badge-outline">
|
|
||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
|
||||||
<DocumentUpload
|
|
||||||
v-model="selectedFiles"
|
|
||||||
title="Déposer vos fichiers"
|
|
||||||
subtitle="Formats acceptés : PDF, images, documents…"
|
|
||||||
@files-added="handleFilesAdded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Téléversement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Chargement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<DocumentListInline
|
|
||||||
v-else
|
|
||||||
:documents="pieceDocuments"
|
|
||||||
:can-delete="canEdit"
|
|
||||||
:can-edit="true"
|
|
||||||
:delete-disabled="uploadingDocuments"
|
|
||||||
empty-text="Aucun document n'est associé à cette pièce pour le moment."
|
|
||||||
@preview="openPreview"
|
|
||||||
@edit="openEditModal"
|
|
||||||
@delete="removeDocument"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EntityHistorySection
|
|
||||||
:entries="history"
|
|
||||||
:loading="historyLoading"
|
|
||||||
:error="historyError"
|
|
||||||
:field-labels="historyFieldLabels"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
|
||||||
Annuler
|
|
||||||
</NuxtLink>
|
|
||||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
|
|
||||||
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
|
|
||||||
Enregistrer les modifications
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comments -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<CommentSection
|
|
||||||
entity-type="piece"
|
|
||||||
:entity-id="String(route.params.id)"
|
|
||||||
:entity-name="piece?.name"
|
|
||||||
show-resolved
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRoute } from '#imports'
|
|
||||||
import { usePieceEdit } from '~/composables/usePieceEdit'
|
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const { updateDocument } = useDocuments()
|
|
||||||
|
|
||||||
const {
|
|
||||||
piece,
|
|
||||||
loading,
|
|
||||||
saving,
|
|
||||||
selectedFiles,
|
|
||||||
uploadingDocuments,
|
|
||||||
loadingDocuments,
|
|
||||||
pieceDocuments,
|
|
||||||
previewDocument,
|
|
||||||
previewVisible,
|
|
||||||
selectedTypeId,
|
|
||||||
editionForm,
|
|
||||||
productSelections,
|
|
||||||
customFieldInputs,
|
|
||||||
canEdit,
|
|
||||||
pieceTypeList,
|
|
||||||
selectedType,
|
|
||||||
resolvedStructure,
|
|
||||||
structureProducts,
|
|
||||||
productRequirementDescriptions,
|
|
||||||
productRequirementEntries,
|
|
||||||
canSubmit,
|
|
||||||
historyFieldLabels,
|
|
||||||
history,
|
|
||||||
historyLoading,
|
|
||||||
historyError,
|
|
||||||
openPreview,
|
|
||||||
closePreview,
|
|
||||||
removeDocument,
|
|
||||||
handleFilesAdded,
|
|
||||||
setProductSelection,
|
|
||||||
submitEdition,
|
|
||||||
formatPieceStructurePreview,
|
|
||||||
} = usePieceEdit(String(route.params.id))
|
|
||||||
|
|
||||||
const editingDocument = ref<any | null>(null)
|
|
||||||
const editModalVisible = ref(false)
|
|
||||||
|
|
||||||
const openEditModal = (doc: any) => {
|
|
||||||
editingDocument.value = doc
|
|
||||||
editModalVisible.value = true
|
|
||||||
}
|
|
||||||
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
|
|
||||||
if (!editingDocument.value?.id) return
|
|
||||||
const result = await updateDocument(editingDocument.value.id, data)
|
|
||||||
if (result.success) {
|
|
||||||
const idx = pieceDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
|
|
||||||
if (idx !== -1) {
|
|
||||||
pieceDocuments.value[idx] = { ...pieceDocuments.value[idx], ...data }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editModalVisible.value = false
|
|
||||||
editingDocument.value = null
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,206 +1,238 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
<main class="container mx-auto px-6 py-10">
|
||||||
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
<div class="space-y-1">
|
|
||||||
<h1 class="text-3xl font-semibold text-base-content">Nouvelle pièce</h1>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<DetailHeader
|
||||||
<div class="form-control">
|
title="Nouvelle pièce"
|
||||||
<label class="label">
|
subtitle="Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce."
|
||||||
<span class="label-text">Catégorie de pièce</span>
|
:is-edit-mode="false"
|
||||||
</label>
|
:can-edit="false"
|
||||||
<SearchSelect
|
back-link="/catalogues/pieces"
|
||||||
v-model="selectedTypeId"
|
|
||||||
:options="pieceTypeList"
|
|
||||||
:loading="loadingTypes"
|
|
||||||
size="sm"
|
|
||||||
placeholder="Rechercher une catégorie..."
|
|
||||||
empty-text="Aucune catégorie disponible"
|
|
||||||
:option-label="typeOptionLabel"
|
|
||||||
:option-description="typeOptionDescription"
|
|
||||||
:disabled="!canEdit || loadingTypes || submitting"
|
|
||||||
/>
|
|
||||||
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
|
||||||
Chargement des catégories…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom de la pièce</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="creationForm.name"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Nom affiché dans le catalogue"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Description</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="creationForm.description"
|
|
||||||
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Description de la pièce (optionnel)"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Référence</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="creationForm.reference"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Référence interne ou fournisseur"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Fournisseur</span>
|
|
||||||
</label>
|
|
||||||
<ConstructeurSelect
|
|
||||||
v-model="creationForm.constructeurIds"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ConstructeurLinksTable
|
|
||||||
v-if="constructeurLinks.length"
|
|
||||||
v-model="constructeurLinks"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections de la pièce">
|
||||||
<div class="form-control">
|
<template #tab-general>
|
||||||
<label class="label">
|
<div class="space-y-6">
|
||||||
<span class="label-text">Prix indicatif (€)</span>
|
<!-- Catégorie -->
|
||||||
</label>
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<input
|
<div class="form-control">
|
||||||
v-model="creationForm.prix"
|
<label class="label">
|
||||||
type="number"
|
<span class="label-text">Catégorie de pièce</span>
|
||||||
step="0.01"
|
</label>
|
||||||
min="0"
|
<SearchSelect
|
||||||
class="input input-bordered input-sm md:input-md"
|
v-model="selectedTypeId"
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
:options="pieceTypeList"
|
||||||
placeholder="Valeur indicatrice"
|
:loading="loadingTypes"
|
||||||
>
|
size="sm"
|
||||||
</div>
|
placeholder="Rechercher une catégorie..."
|
||||||
</div>
|
empty-text="Aucune catégorie disponible"
|
||||||
|
:option-label="typeOptionLabel"
|
||||||
|
:option-description="typeOptionDescription"
|
||||||
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
|
/>
|
||||||
|
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
||||||
|
Chargement des catégories…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<!-- Nom -->
|
||||||
v-if="structureProducts.length"
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
<div class="form-control">
|
||||||
>
|
<label class="label">
|
||||||
<header class="space-y-1">
|
<span class="label-text">Nom de la pièce</span>
|
||||||
<h2 class="font-semibold text-base-content">
|
</label>
|
||||||
Produit requis par le squelette
|
<input
|
||||||
</h2>
|
v-model="creationForm.name"
|
||||||
<p class="text-xs text-base-content/70">
|
type="text"
|
||||||
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
|
class="input input-bordered input-sm md:input-md"
|
||||||
</p>
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
</header>
|
placeholder="Nom affiché dans le catalogue"
|
||||||
<ul class="space-y-2 text-sm text-base-content/80">
|
required
|
||||||
<li
|
>
|
||||||
v-for="(description, index) in productRequirementDescriptions"
|
</div>
|
||||||
:key="`requirement-${index}`"
|
</div>
|
||||||
class="flex items-start gap-2"
|
|
||||||
>
|
<!-- Description -->
|
||||||
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
|
<div class="form-control">
|
||||||
<span>{{ description }}</span>
|
<label class="label">
|
||||||
</li>
|
<span class="label-text">Description</span>
|
||||||
</ul>
|
</label>
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<textarea
|
||||||
<div
|
v-model="creationForm.description"
|
||||||
v-for="entry in productRequirementEntries"
|
class="textarea textarea-bordered textarea-sm md:textarea-md"
|
||||||
:key="entry.key"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
class="form-control"
|
placeholder="Description de la pièce (optionnel)"
|
||||||
>
|
rows="3"
|
||||||
<label class="label">
|
/>
|
||||||
<span class="label-text text-xs font-medium">
|
</div>
|
||||||
{{ entry.label }}
|
|
||||||
</span>
|
<!-- Référence + Fournisseurs -->
|
||||||
</label>
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<ProductSelect
|
<div class="form-control">
|
||||||
:model-value="productSelections[entry.index] || null"
|
<label class="label">
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
<span class="label-text">Référence</span>
|
||||||
:type-product-id="entry.typeProductId"
|
</label>
|
||||||
helper-text="Un produit est requis pour cette pièce."
|
<input
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
v-model="creationForm.reference"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Référence interne ou fournisseur"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Fournisseur</span>
|
||||||
|
</label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
v-model="creationForm.constructeurIds"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConstructeurLinksTable
|
||||||
|
v-if="constructeurLinks.length"
|
||||||
|
v-model="constructeurLinks"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Prix -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Prix indicatif (€)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="creationForm.prix"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Valeur indicatrice"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skeleton preview -->
|
||||||
|
<StructureSkeletonPreview
|
||||||
|
v-if="selectedType"
|
||||||
|
:structure="selectedType.structure"
|
||||||
|
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
|
||||||
|
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
|
||||||
|
variant="piece"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<StructureSkeletonPreview
|
<template #tab-products>
|
||||||
v-if="selectedType"
|
<div class="space-y-6">
|
||||||
:structure="selectedType.structure"
|
<div
|
||||||
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
|
v-if="structureProducts.length"
|
||||||
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
|
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
||||||
variant="piece"
|
>
|
||||||
/>
|
<header class="space-y-1">
|
||||||
|
<h2 class="font-semibold text-base-content">
|
||||||
|
Produit requis par le squelette
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<ul class="space-y-2 text-sm text-base-content/80">
|
||||||
|
<li
|
||||||
|
v-for="(description, index) in productRequirementDescriptions"
|
||||||
|
:key="`requirement-${index}`"
|
||||||
|
class="flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
|
||||||
|
<span>{{ description }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="entry in productRequirementEntries"
|
||||||
|
:key="entry.key"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs font-medium">
|
||||||
|
{{ entry.label }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<ProductSelect
|
||||||
|
:model-value="productSelections[entry.index] || null"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
:type-product-id="entry.typeProductId"
|
||||||
|
helper-text="Sélectionnez un produit (optionnel)."
|
||||||
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<EmptyState
|
||||||
<header class="space-y-1">
|
v-if="!structureProducts.length"
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
title="Aucun produit requis"
|
||||||
<p class="text-xs text-base-content/70">
|
:description="selectedType ? 'Cette catégorie ne requiert pas de produit lié.' : 'Sélectionnez une catégorie pour voir les produits requis.'"
|
||||||
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
|
/>
|
||||||
</p>
|
</div>
|
||||||
</header>
|
</template>
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<template #tab-documents>
|
||||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
<div>
|
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
<div>
|
||||||
<p class="text-xs text-base-content/70">
|
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||||
Ajoutez des documents (PDF, images, textes…) liés à cette pièce.
|
<p class="text-xs text-base-content/70">
|
||||||
|
Ajoutez des documents (PDF, images, textes…) liés à cette pièce.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
||||||
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
||||||
|
<DocumentUpload
|
||||||
|
v-model="selectedDocuments"
|
||||||
|
title="Déposer vos fichiers"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Téléversement des documents en cours…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
</template>
|
||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
|
||||||
<DocumentUpload
|
|
||||||
v-model="selectedDocuments"
|
|
||||||
title="Déposer vos fichiers"
|
|
||||||
subtitle="Formats acceptés : PDF, images, documents…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Téléversement des documents en cours…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<template #tab-custom-fields>
|
||||||
|
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
title="Aucun champ personnalisé"
|
||||||
|
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</EntityTabs>
|
||||||
|
|
||||||
|
<!-- Save/Cancel buttons -->
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
<NuxtLink to="/catalogues/pieces" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
||||||
Annuler
|
Annuler
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
||||||
@@ -208,6 +240,9 @@
|
|||||||
Créer la pièce
|
Créer la pièce
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires avant de créer la pièce.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -225,7 +260,6 @@ import { usePieceTypes } from '~/composables/usePieceTypes'
|
|||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
@@ -239,16 +273,10 @@ import {
|
|||||||
buildProductRequirementDescriptions,
|
buildProductRequirementDescriptions,
|
||||||
buildProductRequirementEntries,
|
buildProductRequirementEntries,
|
||||||
resizeProductSelections,
|
resizeProductSelections,
|
||||||
areProductSelectionsFilled,
|
|
||||||
applyProductSelection,
|
applyProductSelection,
|
||||||
collectNormalizedProductIds,
|
collectNormalizedProductIds,
|
||||||
} from '~/shared/utils/pieceProductSelectionUtils'
|
} from '~/shared/utils/pieceProductSelectionUtils'
|
||||||
import {
|
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
|
||||||
type CustomFieldInput,
|
|
||||||
normalizeCustomFieldInputs,
|
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
|
||||||
|
|
||||||
interface PieceCatalogType extends ModelType {
|
interface PieceCatalogType extends ModelType {
|
||||||
structure: PieceModelStructure | null
|
structure: PieceModelStructure | null
|
||||||
@@ -261,12 +289,12 @@ const router = useRouter()
|
|||||||
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
|
||||||
const { createPiece } = usePieces()
|
const { createPiece } = usePieces()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
const { syncLinks } = useConstructeurLinks()
|
const { syncLinks } = useConstructeurLinks()
|
||||||
const { getConstructeurById } = useConstructeurs()
|
const { getConstructeurById } = useConstructeurs()
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
|
const activeTab = ref('general')
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -281,7 +309,16 @@ const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
|||||||
const productSelections = ref<(string | null)[]>([])
|
const productSelections = ref<(string | null)[]>([])
|
||||||
|
|
||||||
const lastSuggestedName = ref('')
|
const lastSuggestedName = ref('')
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const cfDefinitions = ref<any[]>([])
|
||||||
|
const createdEntityId = ref<string | null>(null)
|
||||||
|
const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, saveAll: saveAllCustomFields } = useCustomFieldInputs({
|
||||||
|
definitions: cfDefinitions,
|
||||||
|
values: [] as any[],
|
||||||
|
entityType: 'piece' as CustomFieldEntityType,
|
||||||
|
entityId: createdEntityId,
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
const selectedDocuments = ref<File[]>([])
|
const selectedDocuments = ref<File[]>([])
|
||||||
const uploadingDocuments = ref(false)
|
const uploadingDocuments = ref(false)
|
||||||
|
|
||||||
@@ -341,13 +378,7 @@ const productRequirementEntries = computed(() =>
|
|||||||
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
|
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const productSelectionsFilled = computed(() =>
|
const productSelectionsFilled = computed(() => true)
|
||||||
areProductSelectionsFilled(
|
|
||||||
requiresProductSelection.value,
|
|
||||||
productRequirementEntries.value,
|
|
||||||
productSelections.value,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const setProductSelection = (index: number, value: string | null) => {
|
const setProductSelection = (index: number, value: string | null) => {
|
||||||
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
||||||
@@ -360,21 +391,17 @@ watch(structureProducts, (products) => {
|
|||||||
watch(selectedType, (type) => {
|
watch(selectedType, (type) => {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
clearCreationForm()
|
clearCreationForm()
|
||||||
customFieldInputs.value = []
|
cfDefinitions.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
|
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
|
||||||
creationForm.name = type.name
|
creationForm.name = type.name
|
||||||
}
|
}
|
||||||
lastSuggestedName.value = creationForm.name
|
lastSuggestedName.value = creationForm.name
|
||||||
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
cfDefinitions.value = type.structure?.customFields ?? []
|
||||||
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
|
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
|
||||||
})
|
})
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
|
||||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
canEdit.value &&
|
canEdit.value &&
|
||||||
@@ -386,6 +413,13 @@ const canSubmit = computed(() =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const entityTabs = computed(() => [
|
||||||
|
{ key: 'general', label: 'Général' },
|
||||||
|
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
|
||||||
|
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
|
||||||
|
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
|
||||||
|
])
|
||||||
|
|
||||||
const clearCreationForm = () => {
|
const clearCreationForm = () => {
|
||||||
creationForm.name = ''
|
creationForm.name = ''
|
||||||
creationForm.description = ''
|
creationForm.description = ''
|
||||||
@@ -403,11 +437,6 @@ const submitCreation = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!productSelectionsFilled.value) {
|
|
||||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
name: creationForm.name.trim(),
|
name: creationForm.name.trim(),
|
||||||
typePieceId: selectedType.value.id,
|
typePieceId: selectedType.value.id,
|
||||||
@@ -450,14 +479,11 @@ const submitCreation = async () => {
|
|||||||
const result = await createPiece(payload)
|
const result = await createPiece(payload)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const createdPiece = result.data as Record<string, any>
|
const createdPiece = result.data as Record<string, any>
|
||||||
await _saveCustomFieldValues(
|
createdEntityId.value = createdPiece.id
|
||||||
'piece',
|
const failedFields = await saveAllCustomFields()
|
||||||
createdPiece.id,
|
if (failedFields.length) {
|
||||||
[
|
toast.showError(`Pièce créée, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||||
createdPiece?.typePiece?.structure?.customFields,
|
}
|
||||||
],
|
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
|
||||||
)
|
|
||||||
// Sync constructeur links after creation
|
// Sync constructeur links after creation
|
||||||
if (constructeurLinks.value.length) {
|
if (constructeurLinks.value.length) {
|
||||||
await syncLinks('piece', createdPiece.id, [], constructeurLinks.value)
|
await syncLinks('piece', createdPiece.id, [], constructeurLinks.value)
|
||||||
@@ -515,5 +541,4 @@ watch(
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadPieceTypes()
|
await loadPieceTypes()
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="container mx-auto px-6 py-10 space-y-8">
|
|
||||||
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-semibold text-base-content">Catalogue des produits</h1>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Retrouvez l'ensemble des produits du catalogue, leurs informations fournisseurs et leurs catégories.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<NuxtLink to="/product/create" class="btn btn-primary btn-sm md:btn-md">
|
|
||||||
Ajouter un produit
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/product-category" class="btn btn-outline btn-sm md:btn-md">
|
|
||||||
Gérer les catégories
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
|
||||||
<div class="card-body space-y-4">
|
|
||||||
<div
|
|
||||||
v-if="errorMessage"
|
|
||||||
class="alert alert-error"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="font-semibold">Impossible de charger les produits</span>
|
|
||||||
<span class="text-sm">{{ errorMessage }}</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm ml-auto" @click="reload">
|
|
||||||
Réessayer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
v-else
|
|
||||||
:columns="columns"
|
|
||||||
:rows="productRows"
|
|
||||||
:loading="loading"
|
|
||||||
:sort="table.sort.value"
|
|
||||||
:pagination="paginationState"
|
|
||||||
:column-filters="table.columnFilters.value"
|
|
||||||
:show-per-page="true"
|
|
||||||
empty-message="Aucun produit n'a encore été enregistré."
|
|
||||||
no-results-message="Aucun produit ne correspond à votre recherche."
|
|
||||||
@sort="table.handleSort"
|
|
||||||
@update:current-page="table.handlePageChange"
|
|
||||||
@update:per-page="table.handlePerPageChange"
|
|
||||||
@update:column-filters="table.handleColumnFiltersChange"
|
|
||||||
>
|
|
||||||
<template #toolbar>
|
|
||||||
<label class="w-full sm:w-72">
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
|
||||||
<input
|
|
||||||
v-model="table.searchTerm.value"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm w-full mt-1"
|
|
||||||
placeholder="Nom ou référence…"
|
|
||||||
@input="table.debouncedSearch"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-preview="{ row }">
|
|
||||||
<DocumentThumbnail
|
|
||||||
:document="resolvePrimaryDocument(row.product, true)"
|
|
||||||
:alt="resolvePreviewAlt(row.product)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-name="{ row }">
|
|
||||||
<span class="font-medium">{{ row.product.name }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-reference="{ row }">
|
|
||||||
{{ row.product.reference || '—' }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-typeProduct="{ row }">
|
|
||||||
<NuxtLink
|
|
||||||
v-if="row.product.typeProduct?.id"
|
|
||||||
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
|
||||||
class="link link-hover link-primary"
|
|
||||||
>
|
|
||||||
{{ row.product.typeProduct.name }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-suppliers="{ row }">
|
|
||||||
<div
|
|
||||||
v-if="row.suppliers.visible.length"
|
|
||||||
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
|
|
||||||
:title="row.suppliers.tooltip"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-for="supplier in row.suppliers.visible"
|
|
||||||
:key="supplier"
|
|
||||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{{ supplier }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="row.suppliers.overflow"
|
|
||||||
class="badge badge-outline badge-sm"
|
|
||||||
>
|
|
||||||
+{{ row.suppliers.overflow }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span v-else class="text-sm text-base-content/50">—</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-price="{ row }">
|
|
||||||
{{ formatPrice(row.product.supplierPrice) }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
@click="navigateTo(`/product/${row.product.id}?edit=true`)"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs text-error"
|
|
||||||
@click="confirmDelete(row.product)"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<NuxtLink
|
|
||||||
:to="`/product/${row.product.id}`"
|
|
||||||
class="btn btn-primary btn-xs"
|
|
||||||
>
|
|
||||||
Détails
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
import { useHead } from '#imports'
|
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
|
||||||
import { useProducts } from '~/composables/useProducts'
|
|
||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
|
||||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
|
||||||
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
|
||||||
|
|
||||||
useHead(() => ({ title: 'Catalogue des produits' }))
|
|
||||||
|
|
||||||
const {
|
|
||||||
products,
|
|
||||||
total,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
loadProducts,
|
|
||||||
deleteProduct,
|
|
||||||
} = useProducts()
|
|
||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const table = useDataTable(
|
|
||||||
{ fetchData: fetchProducts },
|
|
||||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeProduct'] },
|
|
||||||
)
|
|
||||||
|
|
||||||
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
|
|
||||||
{ key: 'name', label: 'Nom', sortable: true },
|
|
||||||
{ key: 'reference', label: 'Référence' },
|
|
||||||
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
|
|
||||||
{ key: 'suppliers', label: 'Fournisseurs' },
|
|
||||||
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
|
|
||||||
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const productsOnPage = computed(() => productRows.value.length)
|
|
||||||
const paginationState = table.pagination(total, productsOnPage)
|
|
||||||
|
|
||||||
// Enrich products with full type data
|
|
||||||
const normalizedProducts = computed(() => {
|
|
||||||
return (Array.isArray(products.value) ? products.value : []).map((product) => {
|
|
||||||
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
|
|
||||||
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const productRows = computed(() =>
|
|
||||||
normalizedProducts.value.map(product => ({
|
|
||||||
id: product.id,
|
|
||||||
product,
|
|
||||||
suppliers: buildProductSuppliersDisplay(product),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function fetchProducts() {
|
|
||||||
await loadProducts({
|
|
||||||
search: table.searchTerm.value,
|
|
||||||
page: table.currentPage.value,
|
|
||||||
itemsPerPage: table.itemsPerPage.value,
|
|
||||||
orderBy: table.sortField.value,
|
|
||||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
|
||||||
typeName: table.columnFilters.value.typeProduct || undefined,
|
|
||||||
force: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const priceFormatter = new Intl.NumberFormat('fr-FR', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR',
|
|
||||||
currencyDisplay: 'narrowSymbol',
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatPrice = (value: any) => {
|
|
||||||
if (value === null || value === undefined || value === '') return '—'
|
|
||||||
const number = Number(value)
|
|
||||||
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
|
|
||||||
buildSuppliersDisplay(resolveSupplierNames(product))
|
|
||||||
|
|
||||||
const reload = () => fetchProducts()
|
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
|
||||||
|
|
||||||
const confirmDelete = async (product: Record<string, any>) => {
|
|
||||||
const productName = product?.name || 'ce produit'
|
|
||||||
const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
|
|
||||||
const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
|
|
||||||
if (!confirmed) return
|
|
||||||
const result = await deleteProduct(product.id)
|
|
||||||
if (result.success) {
|
|
||||||
toast.showSuccess(`Produit "${productName}" supprimé`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([fetchProducts(), loadProductTypes()])
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,564 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<DocumentPreviewModal
|
|
||||||
:document="previewDocument"
|
|
||||||
:visible="previewVisible"
|
|
||||||
:documents="productDocuments"
|
|
||||||
@close="closePreview"
|
|
||||||
/>
|
|
||||||
<DocumentEditModal
|
|
||||||
:visible="editModalVisible"
|
|
||||||
:document="editingDocument"
|
|
||||||
@close="editModalVisible = false"
|
|
||||||
@updated="handleDocumentUpdated"
|
|
||||||
/>
|
|
||||||
<main class="container mx-auto px-6 py-10">
|
|
||||||
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
|
|
||||||
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
|
|
||||||
<p class="text-sm text-base-content/70">Chargement du produit…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!product" class="max-w-xl mx-auto">
|
|
||||||
<div class="alert alert-error shadow-lg">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-lg">Produit introuvable</h2>
|
|
||||||
<p class="text-sm text-base-content/80">
|
|
||||||
Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
|
|
||||||
<div class="card-body space-y-6">
|
|
||||||
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-semibold text-base-content">Modifier le produit</h1>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Mettez à jour les informations du produit et ses champs personnalisés.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Catégorie de produit</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md bg-base-200"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<p class="text-xs text-base-content/60 mt-1">
|
|
||||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom du produit</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="editionForm.name"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Référence</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="editionForm.reference"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Fournisseurs</span>
|
|
||||||
</label>
|
|
||||||
<ConstructeurSelect
|
|
||||||
v-model="editionForm.constructeurIds"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
|
||||||
:initial-options="product?.constructeurs || []"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConstructeurLinksTable
|
|
||||||
v-if="constructeurLinks.length"
|
|
||||||
v-model="constructeurLinks"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Prix fournisseur indicatif (€)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="editionForm.supplierPrice"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-outline">{{ structurePreview }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Mettez à jour les valeurs propres à ce produit.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Gérez les documents associés à ce produit.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span v-if="selectedFiles.length" class="badge badge-outline">
|
|
||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
|
||||||
<DocumentUpload
|
|
||||||
v-model="selectedFiles"
|
|
||||||
title="Déposer vos fichiers"
|
|
||||||
subtitle="Formats acceptés : PDF, images, documents…"
|
|
||||||
@files-added="handleFilesAdded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Téléversement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Chargement des documents…
|
|
||||||
</p>
|
|
||||||
<DocumentListInline
|
|
||||||
v-else
|
|
||||||
:documents="productDocuments"
|
|
||||||
:can-delete="canEdit"
|
|
||||||
:can-edit="true"
|
|
||||||
:delete-disabled="uploadingDocuments || saving"
|
|
||||||
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
|
||||||
@preview="openPreview"
|
|
||||||
@edit="openEditModal"
|
|
||||||
@delete="removeDocument"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EntityHistorySection
|
|
||||||
:entries="history"
|
|
||||||
:loading="historyLoading"
|
|
||||||
:error="historyError"
|
|
||||||
:field-labels="historyFieldLabels"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EntityVersionList
|
|
||||||
entity-type="product"
|
|
||||||
:entity-id="String(route.params.id)"
|
|
||||||
:field-labels="historyFieldLabels"
|
|
||||||
:refresh-key="versionRefreshKey"
|
|
||||||
@restored="loadProduct()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
|
||||||
Annuler
|
|
||||||
</NuxtLink>
|
|
||||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
|
|
||||||
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
|
|
||||||
Enregistrer les modifications
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
|
||||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Comments -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<CommentSection
|
|
||||||
entity-type="product"
|
|
||||||
:entity-id="String(route.params.id)"
|
|
||||||
:entity-name="product?.name"
|
|
||||||
show-resolved
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import { useRoute, useRouter } from '#imports'
|
|
||||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
|
||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
|
||||||
import { useProducts } from '~/composables/useProducts'
|
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
|
||||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
|
||||||
import { useProductHistory } from '~/composables/useProductHistory'
|
|
||||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
|
||||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
|
||||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
|
||||||
import { getModelType } from '~/services/modelTypes'
|
|
||||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
|
||||||
import {
|
|
||||||
type CustomFieldInput,
|
|
||||||
buildCustomFieldInputs,
|
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
|
||||||
const versionRefreshKey = ref(0)
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const toast = useToast()
|
|
||||||
const { getProduct, updateProduct } = useProducts()
|
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
|
||||||
const {
|
|
||||||
loadDocumentsByProduct,
|
|
||||||
uploadDocuments: uploadProductDocuments,
|
|
||||||
deleteDocument: deleteProductDocument,
|
|
||||||
updateDocument,
|
|
||||||
} = useDocuments()
|
|
||||||
const { ensureConstructeurs, getConstructeurById } = useConstructeurs()
|
|
||||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
|
||||||
const {
|
|
||||||
history,
|
|
||||||
loading: historyLoading,
|
|
||||||
error: historyError,
|
|
||||||
loadHistory,
|
|
||||||
} = useProductHistory()
|
|
||||||
|
|
||||||
const product = ref<any | null>(null)
|
|
||||||
const productType = ref<any | null>(null)
|
|
||||||
const structure = ref<ProductModelStructure | null>(null)
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
const selectedFiles = ref<File[]>([])
|
|
||||||
const uploadingDocuments = ref(false)
|
|
||||||
const loadingDocuments = ref(false)
|
|
||||||
const productDocuments = ref<any[]>([])
|
|
||||||
const previewDocument = ref<any | null>(null)
|
|
||||||
const previewVisible = ref(false)
|
|
||||||
const editingDocument = ref<any | null>(null)
|
|
||||||
const editModalVisible = ref(false)
|
|
||||||
|
|
||||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
|
||||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
|
||||||
|
|
||||||
const historyFieldLabels: Record<string, string> = {
|
|
||||||
name: 'Nom',
|
|
||||||
reference: 'Référence',
|
|
||||||
supplierPrice: 'Prix fournisseur',
|
|
||||||
typeProduct: 'Catégorie',
|
|
||||||
constructeurIds: 'Fournisseurs',
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshCustomFieldInputs = (
|
|
||||||
structureOverride?: ProductModelStructure | null,
|
|
||||||
valuesOverride?: any[] | null,
|
|
||||||
) => {
|
|
||||||
const nextStructure = structureOverride ?? structure.value ?? null
|
|
||||||
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
|
|
||||||
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
|
|
||||||
}
|
|
||||||
|
|
||||||
const editionForm = reactive({
|
|
||||||
name: '' as string,
|
|
||||||
reference: '' as string,
|
|
||||||
constructeurIds: [] as string[],
|
|
||||||
supplierPrice: '' as string,
|
|
||||||
})
|
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
|
||||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
|
||||||
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
|
||||||
|
|
||||||
const openPreview = (doc: any) => {
|
|
||||||
if (!doc || !canPreviewDocument(doc)) return
|
|
||||||
previewDocument.value = doc
|
|
||||||
previewVisible.value = true
|
|
||||||
}
|
|
||||||
const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
|
|
||||||
|
|
||||||
const openEditModal = (doc: any) => {
|
|
||||||
editingDocument.value = doc
|
|
||||||
editModalVisible.value = true
|
|
||||||
}
|
|
||||||
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
|
|
||||||
if (!editingDocument.value?.id) return
|
|
||||||
const result = await updateDocument(editingDocument.value.id, data)
|
|
||||||
if (result.success) {
|
|
||||||
const idx = productDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
|
|
||||||
if (idx !== -1) {
|
|
||||||
productDocuments.value[idx] = { ...productDocuments.value[idx], ...data }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editModalVisible.value = false
|
|
||||||
editingDocument.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadProduct = async () => {
|
|
||||||
const id = route.params.id
|
|
||||||
if (!id || typeof id !== 'string') {
|
|
||||||
product.value = null
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await getProduct(id)
|
|
||||||
if (result.success && result.data) {
|
|
||||||
product.value = result.data
|
|
||||||
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
|
|
||||||
|
|
||||||
await loadProductType()
|
|
||||||
|
|
||||||
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
|
||||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
|
||||||
refreshCustomFieldInputs(undefined, customValues)
|
|
||||||
|
|
||||||
hydrateForm()
|
|
||||||
|
|
||||||
// History is non-blocking — template handles its own loading state
|
|
||||||
loadHistory(result.data.id).catch(() => {})
|
|
||||||
} else {
|
|
||||||
product.value = null
|
|
||||||
}
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshDocuments = async () => {
|
|
||||||
if (!product.value?.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loadingDocuments.value = true
|
|
||||||
try {
|
|
||||||
const result = await loadDocumentsByProduct(product.value.id, { updateStore: false })
|
|
||||||
if (result.success) {
|
|
||||||
productDocuments.value = Array.isArray(result.data) ? result.data : []
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loadingDocuments.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeDocument = async (documentId: string | number | null | undefined) => {
|
|
||||||
if (!documentId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await deleteProductDocument(documentId, { updateStore: false })
|
|
||||||
if (result.success) {
|
|
||||||
productDocuments.value = productDocuments.value.filter((doc) => doc.id !== documentId)
|
|
||||||
toast.showSuccess('Document supprimé')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFilesAdded = async (files: File[]) => {
|
|
||||||
if (!files?.length || !product.value?.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uploadingDocuments.value = true
|
|
||||||
try {
|
|
||||||
const result = await uploadProductDocuments(
|
|
||||||
{
|
|
||||||
files,
|
|
||||||
context: { productId: product.value.id },
|
|
||||||
},
|
|
||||||
{ updateStore: false },
|
|
||||||
)
|
|
||||||
if (result.success) {
|
|
||||||
selectedFiles.value = []
|
|
||||||
await refreshDocuments()
|
|
||||||
toast.showSuccess('Document(s) ajouté(s)')
|
|
||||||
} else if (result.error) {
|
|
||||||
toast.showError(result.error)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
uploadingDocuments.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadProductType = async () => {
|
|
||||||
// Try using the expanded typeProduct from entity response first
|
|
||||||
const embedded = product.value?.typeProduct
|
|
||||||
if (embedded && typeof embedded === 'object' && embedded.id) {
|
|
||||||
const embeddedStructure = embedded.structure ?? null
|
|
||||||
if (embeddedStructure) {
|
|
||||||
productType.value = embedded
|
|
||||||
structure.value = normalizeProductStructureForSave(embeddedStructure)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!product.value?.typeProductId) {
|
|
||||||
productType.value = embedded ?? null
|
|
||||||
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const type = await getModelType(product.value.typeProductId)
|
|
||||||
productType.value = type
|
|
||||||
structure.value = normalizeProductStructureForSave(type?.structure ?? null)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors du chargement du type de produit:', error)
|
|
||||||
productType.value = embedded ?? null
|
|
||||||
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrateForm = () => {
|
|
||||||
if (!product.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
editionForm.name = product.value.name || ''
|
|
||||||
editionForm.reference = product.value.reference || ''
|
|
||||||
// Load constructeur links
|
|
||||||
fetchLinks('product', String(route.params.id)).then((links) => {
|
|
||||||
constructeurLinks.value = links
|
|
||||||
originalConstructeurLinks.value = links.map(l => ({ ...l }))
|
|
||||||
editionForm.constructeurIds = constructeurIdsFromLinks(links)
|
|
||||||
if (editionForm.constructeurIds.length) {
|
|
||||||
void ensureConstructeurs(editionForm.constructeurIds)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
|
|
||||||
? String(product.value.supplierPrice)
|
|
||||||
: ''
|
|
||||||
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => product.value?.documents,
|
|
||||||
(docs) => {
|
|
||||||
if (Array.isArray(docs)) {
|
|
||||||
productDocuments.value = docs
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const submitEdition = async () => {
|
|
||||||
if (!product.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
|
||||||
name: editionForm.name.trim(),
|
|
||||||
reference: editionForm.reference.trim() || null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPrice = typeof editionForm.supplierPrice === 'string'
|
|
||||||
? editionForm.supplierPrice.trim()
|
|
||||||
: editionForm.supplierPrice
|
|
||||||
payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
|
|
||||||
? Number.isNaN(Number(rawPrice))
|
|
||||||
? null
|
|
||||||
: String(Number(rawPrice))
|
|
||||||
: null
|
|
||||||
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const result = await updateProduct(product.value.id, payload)
|
|
||||||
if (result.success && result.data?.id) {
|
|
||||||
product.value = result.data
|
|
||||||
const failedFields = await _saveCustomFieldValues(
|
|
||||||
'product',
|
|
||||||
result.data.id,
|
|
||||||
[result.data?.typeProduct?.structure?.customFields],
|
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
|
||||||
)
|
|
||||||
if (failedFields.length) {
|
|
||||||
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await syncLinks('product', product.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
|
||||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
|
||||||
toast.showSuccess('Produit mis à jour avec succès')
|
|
||||||
versionRefreshKey.value++
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
|
||||||
watch(
|
|
||||||
() => editionForm.constructeurIds,
|
|
||||||
(ids) => {
|
|
||||||
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
|
|
||||||
for (const id of ids) {
|
|
||||||
if (!currentIds.has(id)) {
|
|
||||||
const resolved = getConstructeurById(id)
|
|
||||||
constructeurLinks.value.push({
|
|
||||||
constructeurId: id,
|
|
||||||
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
|
|
||||||
supplierReference: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadProduct()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -39,239 +39,297 @@
|
|||||||
:subtitle="isEditMode ? 'Ajustez les informations du produit et ses champs personnalisés.' : undefined"
|
:subtitle="isEditMode ? 'Ajustez les informations du produit et ses champs personnalisés.' : undefined"
|
||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
:can-edit="canEdit"
|
:can-edit="canEdit"
|
||||||
back-link="/product-catalog"
|
back-link="/catalogues/produits"
|
||||||
@toggle-edit="isEditMode = !isEditMode"
|
@toggle-edit="isEditMode = !isEditMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Catégorie (always shown) -->
|
<EntityTabs
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
v-model="activeTab"
|
||||||
<div class="form-control">
|
:tabs="entityTabs"
|
||||||
<label class="label">
|
aria-label="Sections du produit"
|
||||||
<span class="label-text">Catégorie de produit</span>
|
|
||||||
</label>
|
|
||||||
<template v-if="isEditMode">
|
|
||||||
<input
|
|
||||||
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md bg-base-200"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<p class="text-xs text-base-content/60 mt-1">
|
|
||||||
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
|
||||||
{{ product?.typeProduct?.name || '—' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nom (always shown) -->
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom du produit</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-if="isEditMode"
|
|
||||||
v-model="editionForm.name"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Nom affiché dans le catalogue"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
|
||||||
{{ product.name }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Référence + Fournisseurs (if value or edit mode) -->
|
|
||||||
<div
|
|
||||||
v-if="isEditMode || product.reference || editionForm.constructeurIds.length"
|
|
||||||
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
|
||||||
>
|
>
|
||||||
<div v-if="isEditMode || product.reference" class="form-control">
|
<template #tab-general>
|
||||||
<label class="label">
|
<div class="space-y-6">
|
||||||
<span class="label-text">Référence</span>
|
<!-- Catégorie (always shown) -->
|
||||||
</label>
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<input
|
<div class="form-control">
|
||||||
v-if="isEditMode"
|
<label class="label">
|
||||||
v-model="editionForm.reference"
|
<span class="label-text">Catégorie de produit</span>
|
||||||
type="text"
|
</label>
|
||||||
class="input input-bordered input-sm md:input-md"
|
<template v-if="isEditMode">
|
||||||
:disabled="!canEdit || saving"
|
<div class="flex items-center gap-2">
|
||||||
placeholder="Référence interne ou fournisseur"
|
<input
|
||||||
>
|
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
type="text"
|
||||||
{{ product.reference }}
|
class="input input-bordered input-sm md:input-md bg-base-200 flex-1"
|
||||||
</div>
|
disabled
|
||||||
</div>
|
>
|
||||||
|
<NuxtLink
|
||||||
<div v-if="isEditMode || editionForm.constructeurIds.length" class="form-control">
|
v-if="product?.typeProduct?.id"
|
||||||
<label class="label">
|
:to="`/product-category/${product.typeProduct.id}/edit`"
|
||||||
<span class="label-text">Fournisseurs</span>
|
class="btn btn-ghost btn-sm"
|
||||||
</label>
|
title="Voir la catégorie"
|
||||||
<ConstructeurSelect
|
>
|
||||||
v-if="isEditMode"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
v-model="editionForm.constructeurIds"
|
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
|
||||||
class="w-full"
|
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
|
||||||
:disabled="!canEdit || saving"
|
</svg>
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
</NuxtLink>
|
||||||
:initial-options="product?.constructeurs || []"
|
</div>
|
||||||
/>
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
<div v-else class="flex flex-wrap gap-2">
|
La catégorie d'origine ne peut pas être modifiée depuis cette page.
|
||||||
<span
|
</p>
|
||||||
v-for="id in editionForm.constructeurIds"
|
</template>
|
||||||
:key="id"
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
class="badge badge-outline"
|
{{ product?.typeProduct?.name || '—' }}
|
||||||
>
|
</p>
|
||||||
{{ getConstructeurById(id)?.name || id }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Constructeur links table -->
|
|
||||||
<ConstructeurLinksTable
|
|
||||||
v-if="isEditMode && constructeurLinks.length"
|
|
||||||
v-model="constructeurLinks"
|
|
||||||
/>
|
|
||||||
<ConstructeurLinksTable
|
|
||||||
v-else-if="!isEditMode && constructeurLinks.length"
|
|
||||||
:model-value="constructeurLinks"
|
|
||||||
:readonly="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Prix fournisseur (if value or edit mode) -->
|
|
||||||
<div v-if="isEditMode || product.supplierPrice" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Prix fournisseur indicatif (€)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-if="isEditMode"
|
|
||||||
v-model="editionForm.supplierPrice"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || saving"
|
|
||||||
placeholder="Valeur indicatrice"
|
|
||||||
>
|
|
||||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
|
||||||
{{ product.supplierPrice }} €
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Structure preview (edit mode only) -->
|
|
||||||
<div v-if="isEditMode && structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-outline">{{ structurePreview }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom fields -->
|
|
||||||
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="space-y-1">
|
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
|
||||||
<p v-if="isEditMode" class="text-xs text-base-content/70">
|
|
||||||
Mettez à jour les valeurs propres à ce produit.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<template v-if="isEditMode">
|
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="field in visibleCustomFields"
|
|
||||||
:key="field.customFieldValueId || field.id || field.name"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-sm">{{ field.name }}</span>
|
|
||||||
</label>
|
|
||||||
<div class="input input-bordered input-sm bg-base-200 flex items-center">
|
|
||||||
{{ field.value }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Nom (always shown) -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Nom du produit</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Nom affiché dans le catalogue"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ product.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Référence + Fournisseurs (if value or edit mode) -->
|
||||||
|
<div
|
||||||
|
v-if="isEditMode || product.reference || editionForm.constructeurIds.length"
|
||||||
|
class="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div v-if="isEditMode || product.reference" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Référence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.reference"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Référence interne ou fournisseur"
|
||||||
|
>
|
||||||
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ product.reference }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isEditMode || editionForm.constructeurIds.length" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Fournisseurs</span>
|
||||||
|
</label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.constructeurIds"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
:initial-options="product?.constructeurs || []"
|
||||||
|
/>
|
||||||
|
<div v-else class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="id in editionForm.constructeurIds"
|
||||||
|
:key="id"
|
||||||
|
class="badge badge-outline"
|
||||||
|
>
|
||||||
|
{{ getConstructeurById(id)?.name || id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Constructeur links table -->
|
||||||
|
<ConstructeurLinksTable
|
||||||
|
v-if="isEditMode && constructeurLinks.length"
|
||||||
|
v-model="constructeurLinks"
|
||||||
|
/>
|
||||||
|
<ConstructeurLinksTable
|
||||||
|
v-else-if="!isEditMode && constructeurLinks.length"
|
||||||
|
:model-value="constructeurLinks"
|
||||||
|
:readonly="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Prix fournisseur (if value or edit mode) -->
|
||||||
|
<div v-if="isEditMode || product.supplierPrice" class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Prix fournisseur indicatif (€)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="isEditMode"
|
||||||
|
v-model="editionForm.supplierPrice"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || saving"
|
||||||
|
placeholder="Valeur indicatrice"
|
||||||
|
>
|
||||||
|
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ product.supplierPrice }} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Structure preview (edit mode only) -->
|
||||||
|
<div v-if="isEditMode && structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline">{{ structurePreview }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UsedInSection entity-type="products" :entity-id="product?.id ?? null" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-documents>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Documents -->
|
||||||
|
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
{{ isEditMode ? 'Gérez les documents associés à ce produit.' : 'Documents associés à ce produit.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
||||||
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<template v-if="isEditMode">
|
||||||
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
|
<DocumentUpload
|
||||||
|
v-model="selectedFiles"
|
||||||
|
title="Déposer vos fichiers"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents…"
|
||||||
|
@files-added="handleFilesAdded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Téléversement des documents en cours…
|
||||||
|
</p>
|
||||||
|
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Chargement des documents en cours…
|
||||||
|
</p>
|
||||||
|
<DocumentListInline
|
||||||
|
v-else
|
||||||
|
:documents="productDocuments"
|
||||||
|
:can-delete="canEdit"
|
||||||
|
:can-edit="true"
|
||||||
|
:delete-disabled="uploadingDocuments || saving"
|
||||||
|
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
||||||
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
|
@delete="removeDocument"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Chargement des documents en cours…
|
||||||
|
</p>
|
||||||
|
<DocumentListInline
|
||||||
|
v-else
|
||||||
|
:documents="productDocuments"
|
||||||
|
:can-delete="false"
|
||||||
|
:can-edit="false"
|
||||||
|
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
||||||
|
@preview="openPreview"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Documents -->
|
<template #tab-custom-fields>
|
||||||
<div
|
<div class="space-y-6">
|
||||||
v-if="isEditMode || productDocuments.length > 0"
|
<!-- Custom fields -->
|
||||||
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
|
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
>
|
<header class="space-y-1">
|
||||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||||
<div>
|
<p v-if="isEditMode" class="text-xs text-base-content/70">
|
||||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
Mettez à jour les valeurs propres à ce produit.
|
||||||
<p class="text-xs text-base-content/70">
|
</p>
|
||||||
{{ isEditMode ? 'Gérez les documents associés à ce produit.' : 'Documents associés à ce produit.' }}
|
</header>
|
||||||
|
<template v-if="isEditMode">
|
||||||
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="field in visibleCustomFields"
|
||||||
|
:key="field.customFieldValueId || field.customFieldId || field.name"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-sm">{{ field.name }}</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-sm font-medium text-base-content py-1">
|
||||||
|
{{ field.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-base-content/60">
|
||||||
|
Aucun champ personnalisé n'est défini pour ce produit.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
|
|
||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<template v-if="isEditMode">
|
|
||||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
|
||||||
<DocumentUpload
|
|
||||||
v-model="selectedFiles"
|
|
||||||
title="Déposer vos fichiers"
|
|
||||||
subtitle="Formats acceptés : PDF, images, documents…"
|
|
||||||
@files-added="handleFilesAdded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Téléversement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Chargement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<DocumentListInline
|
|
||||||
v-else
|
|
||||||
:documents="productDocuments"
|
|
||||||
:can-delete="canEdit"
|
|
||||||
:can-edit="true"
|
|
||||||
:delete-disabled="uploadingDocuments || saving"
|
|
||||||
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
|
||||||
@preview="openPreview"
|
|
||||||
@edit="openEditModal"
|
|
||||||
@delete="removeDocument"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
|
||||||
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
|
|
||||||
Chargement des documents en cours…
|
|
||||||
</p>
|
|
||||||
<DocumentListInline
|
|
||||||
v-else
|
|
||||||
:documents="productDocuments"
|
|
||||||
:can-delete="false"
|
|
||||||
:can-edit="false"
|
|
||||||
empty-text="Aucun document n'est associé à ce produit pour le moment."
|
|
||||||
@preview="openPreview"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EntityHistorySection
|
<template #tab-history>
|
||||||
:entries="history"
|
<div class="space-y-6">
|
||||||
:loading="historyLoading"
|
<EntityHistorySection
|
||||||
:error="historyError"
|
:entries="history"
|
||||||
:field-labels="historyFieldLabels"
|
:loading="historyLoading"
|
||||||
/>
|
:error="historyError"
|
||||||
|
:field-labels="historyFieldLabels"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EntityVersionList
|
||||||
|
entity-type="product"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:field-labels="historyFieldLabels"
|
||||||
|
:refresh-key="versionRefreshKey"
|
||||||
|
@restored="loadProduct()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="product"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="product?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</EntityTabs>
|
||||||
|
|
||||||
<!-- Save buttons (edit mode only) -->
|
<!-- Save buttons (edit mode only) -->
|
||||||
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
@@ -283,19 +341,9 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="isEditMode && product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Comments -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<CommentSection
|
|
||||||
entity-type="product"
|
|
||||||
:entity-id="String(route.params.id)"
|
|
||||||
:entity-name="product?.name"
|
|
||||||
show-resolved
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -304,18 +352,17 @@
|
|||||||
|
|
||||||
<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 { useRoute, useRouter } from '#imports'
|
import { navigateTo, useRoute } from '#imports'
|
||||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||||
import { useProductHistory } from '~/composables/useProductHistory'
|
import { useEntityHistory } from '~/composables/useEntityHistory'
|
||||||
import { usePermissions } from '~/composables/usePermissions'
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||||
@@ -323,19 +370,12 @@ import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
|||||||
import { getModelType } from '~/services/modelTypes'
|
import { getModelType } from '~/services/modelTypes'
|
||||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import {
|
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
|
||||||
type CustomFieldInput,
|
|
||||||
buildCustomFieldInputs,
|
|
||||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
|
||||||
saveCustomFieldValues as _saveCustomFieldValues,
|
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { getProduct, updateProduct } = useProducts()
|
const { getProduct, updateProduct } = useProducts()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
|
||||||
const {
|
const {
|
||||||
loadDocumentsByProduct,
|
loadDocumentsByProduct,
|
||||||
uploadDocuments: uploadProductDocuments,
|
uploadDocuments: uploadProductDocuments,
|
||||||
@@ -349,9 +389,10 @@ const {
|
|||||||
loading: historyLoading,
|
loading: historyLoading,
|
||||||
error: historyError,
|
error: historyError,
|
||||||
loadHistory,
|
loadHistory,
|
||||||
} = useProductHistory()
|
} = useEntityHistory('product')
|
||||||
|
|
||||||
const isEditMode = ref(false)
|
const isEditMode = ref(false)
|
||||||
|
const versionRefreshKey = ref(0)
|
||||||
|
|
||||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||||
@@ -359,7 +400,20 @@ const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
|||||||
const product = ref<any | null>(null)
|
const product = ref<any | null>(null)
|
||||||
const productType = ref<any | null>(null)
|
const productType = ref<any | null>(null)
|
||||||
const structure = ref<ProductModelStructure | null>(null)
|
const structure = ref<ProductModelStructure | null>(null)
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const cfDefinitions = ref<any[]>([])
|
||||||
|
const cfValues = ref<any[]>([])
|
||||||
|
const entityId = computed(() => product.value?.id ?? null)
|
||||||
|
const {
|
||||||
|
fields: customFieldInputs,
|
||||||
|
requiredFilled: requiredCustomFieldsFilled,
|
||||||
|
saveAll: saveAllCustomFields,
|
||||||
|
} = useCustomFieldInputs({
|
||||||
|
definitions: cfDefinitions,
|
||||||
|
values: cfValues,
|
||||||
|
entityType: 'product' as CustomFieldEntityType,
|
||||||
|
entityId,
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const selectedFiles = ref<File[]>([])
|
const selectedFiles = ref<File[]>([])
|
||||||
@@ -385,7 +439,8 @@ const refreshCustomFieldInputs = (
|
|||||||
) => {
|
) => {
|
||||||
const nextStructure = structureOverride ?? structure.value ?? null
|
const nextStructure = structureOverride ?? structure.value ?? null
|
||||||
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
|
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
|
||||||
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
|
cfDefinitions.value = nextStructure?.customFields ?? []
|
||||||
|
cfValues.value = Array.isArray(nextValues) ? nextValues : []
|
||||||
}
|
}
|
||||||
|
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
@@ -395,9 +450,7 @@ const editionForm = reactive({
|
|||||||
supplierPrice: '' as string,
|
supplierPrice: '' as string,
|
||||||
})
|
})
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
||||||
@@ -412,6 +465,18 @@ const visibleCustomFields = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const activeTab = ref((route.query.tab as string) || 'general')
|
||||||
|
watch(activeTab, (val) => {
|
||||||
|
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const entityTabs = computed(() => [
|
||||||
|
{ key: 'general', label: 'Général' },
|
||||||
|
{ key: 'documents', label: 'Documents', count: productDocuments.value.length },
|
||||||
|
{ key: 'custom-fields', label: 'Champs perso', count: visibleCustomFields.value.length },
|
||||||
|
{ key: 'history', label: 'Historique' },
|
||||||
|
])
|
||||||
|
|
||||||
const openPreview = (doc: any) => {
|
const openPreview = (doc: any) => {
|
||||||
if (!doc || !canPreviewDocument(doc)) return
|
if (!doc || !canPreviewDocument(doc)) return
|
||||||
previewDocument.value = doc
|
previewDocument.value = doc
|
||||||
@@ -595,12 +660,7 @@ const submitEdition = async () => {
|
|||||||
const result = await updateProduct(product.value.id, payload)
|
const result = await updateProduct(product.value.id, payload)
|
||||||
if (result.success && result.data?.id) {
|
if (result.success && result.data?.id) {
|
||||||
product.value = result.data
|
product.value = result.data
|
||||||
const failedFields = await _saveCustomFieldValues(
|
const failedFields = await saveAllCustomFields()
|
||||||
'product',
|
|
||||||
result.data.id,
|
|
||||||
[result.data?.typeProduct?.structure?.customFields],
|
|
||||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
|
||||||
)
|
|
||||||
if (failedFields.length) {
|
if (failedFields.length) {
|
||||||
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||||
return
|
return
|
||||||
@@ -610,6 +670,7 @@ const submitEdition = async () => {
|
|||||||
toast.showSuccess('Produit mis à jour avec succès')
|
toast.showSuccess('Produit mis à jour avec succès')
|
||||||
await loadProduct()
|
await loadProduct()
|
||||||
isEditMode.value = false
|
isEditMode.value = false
|
||||||
|
versionRefreshKey.value++
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||||
|
|||||||
@@ -1,158 +1,178 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
<main class="container mx-auto px-6 py-10">
|
||||||
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
<div class="space-y-1">
|
|
||||||
<h1 class="text-3xl font-semibold text-base-content">Nouveau produit</h1>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
|
||||||
Retour au catalogue
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<DetailHeader
|
||||||
<div class="form-control">
|
title="Nouveau produit"
|
||||||
<label class="label">
|
subtitle="Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue."
|
||||||
<span class="label-text">Catégorie de produit</span>
|
:is-edit-mode="false"
|
||||||
</label>
|
:can-edit="false"
|
||||||
<SearchSelect
|
back-link="/catalogues/produits"
|
||||||
v-model="selectedTypeId"
|
|
||||||
:options="productTypeList"
|
|
||||||
:loading="loadingTypes"
|
|
||||||
size="sm"
|
|
||||||
placeholder="Rechercher une catégorie..."
|
|
||||||
empty-text="Aucune catégorie disponible"
|
|
||||||
:option-label="typeOptionLabel"
|
|
||||||
:option-description="typeOptionDescription"
|
|
||||||
:disabled="!canEdit || loadingTypes || submitting"
|
|
||||||
/>
|
|
||||||
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
|
||||||
Chargement des catégories…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Nom du produit</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="creationForm.name"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Nom affiché dans le catalogue"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Référence</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="creationForm.reference"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm md:input-md"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Référence interne ou fournisseur"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Fournisseurs</span>
|
|
||||||
</label>
|
|
||||||
<ConstructeurSelect
|
|
||||||
v-model="creationForm.constructeurIds"
|
|
||||||
class="w-full"
|
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConstructeurLinksTable
|
|
||||||
v-if="constructeurLinks.length"
|
|
||||||
v-model="constructeurLinks"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections du produit">
|
||||||
<div class="form-control">
|
<template #tab-general>
|
||||||
<label class="label">
|
<div class="space-y-6">
|
||||||
<span class="label-text">Prix fournisseur indicatif (€)</span>
|
<!-- Catégorie -->
|
||||||
</label>
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<input
|
<div class="form-control">
|
||||||
v-model="creationForm.supplierPrice"
|
<label class="label">
|
||||||
type="number"
|
<span class="label-text">Catégorie de produit</span>
|
||||||
step="0.01"
|
</label>
|
||||||
min="0"
|
<SearchSelect
|
||||||
class="input input-bordered input-sm md:input-md"
|
v-model="selectedTypeId"
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
:options="productTypeList"
|
||||||
placeholder="Valeur indicatrice"
|
:loading="loadingTypes"
|
||||||
>
|
size="sm"
|
||||||
</div>
|
placeholder="Rechercher une catégorie..."
|
||||||
</div>
|
empty-text="Aucune catégorie disponible"
|
||||||
|
:option-label="typeOptionLabel"
|
||||||
|
:option-description="typeOptionDescription"
|
||||||
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
|
/>
|
||||||
|
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
||||||
|
Chargement des catégories…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<!-- Nom -->
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div class="form-control">
|
||||||
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
<label class="label">
|
||||||
<p class="text-xs text-base-content/70">
|
<span class="label-text">Nom du produit</span>
|
||||||
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="creationForm.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Nom affiché dans le catalogue"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Référence + Fournisseurs -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Référence</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="creationForm.reference"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Référence interne ou fournisseur"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Fournisseurs</span>
|
||||||
|
</label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
v-model="creationForm.constructeurIds"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConstructeurLinksTable
|
||||||
|
v-if="constructeurLinks.length"
|
||||||
|
v-model="constructeurLinks"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Prix -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Prix fournisseur indicatif (€)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="creationForm.supplierPrice"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
class="input input-bordered input-sm md:input-md"
|
||||||
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
|
placeholder="Valeur indicatrice"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skeleton preview -->
|
||||||
|
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
|
||||||
|
Cette catégorie ne définit pas encore de champs personnalisés.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #tab-documents>
|
||||||
|
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
|
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
|
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
||||||
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
|
||||||
|
<DocumentUpload
|
||||||
|
v-model="selectedDocuments"
|
||||||
|
title="Déposer vos fichiers"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||||
|
Téléversement des documents en cours…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
|
<template #tab-custom-fields>
|
||||||
Cette catégorie ne définit pas encore de champs personnalisés.
|
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||||
</p>
|
<header class="space-y-1">
|
||||||
</div>
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||||
|
<p class="text-xs text-base-content/70">
|
||||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
Renseignez les valeurs propres à ce produit catalogue.
|
||||||
<header class="space-y-1">
|
</p>
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
</header>
|
||||||
<p class="text-xs text-base-content/70">
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
Renseignez les valeurs propres à ce produit catalogue.
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
</p>
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
</header>
|
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
|
||||||
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
|
||||||
<p class="text-xs text-base-content/70">
|
|
||||||
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
<EmptyState
|
||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
v-else
|
||||||
</span>
|
title="Aucun champ personnalisé"
|
||||||
</header>
|
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
|
||||||
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
|
|
||||||
<DocumentUpload
|
|
||||||
v-model="selectedDocuments"
|
|
||||||
title="Déposer vos fichiers"
|
|
||||||
subtitle="Formats acceptés : PDF, images, documents…"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
</EntityTabs>
|
||||||
Téléversement des documents en cours…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Save/Cancel buttons -->
|
||||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
<NuxtLink to="/catalogues/produits" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
|
||||||
Annuler
|
Annuler
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
||||||
@@ -160,7 +180,7 @@
|
|||||||
Créer le produit
|
Créer le produit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="selectedType && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +197,6 @@ import DocumentUpload from '~/components/DocumentUpload.vue'
|
|||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||||
@@ -186,10 +205,7 @@ import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
|||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||||
import type { ModelType } from '~/services/modelTypes'
|
import type { ModelType } from '~/services/modelTypes'
|
||||||
import {
|
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
|
||||||
type CustomFieldInput,
|
|
||||||
normalizeCustomFieldInputs as normalizeCustomFieldInputsFromUtils,
|
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
|
||||||
|
|
||||||
interface ProductCatalogType extends ModelType {
|
interface ProductCatalogType extends ModelType {
|
||||||
structure: ProductModelStructure | null
|
structure: ProductModelStructure | null
|
||||||
@@ -202,12 +218,12 @@ const router = useRouter()
|
|||||||
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
|
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
|
||||||
const { createProduct } = useProducts()
|
const { createProduct } = useProducts()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue } = useCustomFields()
|
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const { syncLinks } = useConstructeurLinks()
|
const { syncLinks } = useConstructeurLinks()
|
||||||
const { getConstructeurById } = useConstructeurs()
|
const { getConstructeurById } = useConstructeurs()
|
||||||
|
|
||||||
|
const activeTab = ref('general')
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -221,7 +237,16 @@ const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
|||||||
const selectedDocuments = ref<File[]>([])
|
const selectedDocuments = ref<File[]>([])
|
||||||
const uploadingDocuments = ref(false)
|
const uploadingDocuments = ref(false)
|
||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const cfDefinitions = ref<any[]>([])
|
||||||
|
const createdEntityId = ref<string | null>(null)
|
||||||
|
const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, saveAll: saveAllCustomFields } = useCustomFieldInputs({
|
||||||
|
definitions: cfDefinitions,
|
||||||
|
values: [] as any[],
|
||||||
|
entityType: 'product' as CustomFieldEntityType,
|
||||||
|
entityId: createdEntityId,
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
const productTypeList = computed<ProductCatalogType[]>(() =>
|
const productTypeList = computed<ProductCatalogType[]>(() =>
|
||||||
(productTypes.value || []) as ProductCatalogType[],
|
(productTypes.value || []) as ProductCatalogType[],
|
||||||
@@ -238,6 +263,12 @@ const selectedType = computed(() => {
|
|||||||
return productTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
return productTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const entityTabs = computed(() => [
|
||||||
|
{ key: 'general', label: 'Général' },
|
||||||
|
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
|
||||||
|
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
|
||||||
|
])
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.typeId,
|
() => route.query.typeId,
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -264,27 +295,16 @@ watch(selectedTypeId, (id) => {
|
|||||||
watch(selectedType, (type) => {
|
watch(selectedType, (type) => {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
clearForm()
|
clearForm()
|
||||||
customFieldInputs.value = []
|
cfDefinitions.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!creationForm.name) {
|
if (!creationForm.name) {
|
||||||
creationForm.name = type.name
|
creationForm.name = type.name
|
||||||
}
|
}
|
||||||
customFieldInputs.value = normalizeCustomFieldInputsFromUtils(normalizeProductStructureForSave(type.structure))
|
const normalized = normalizeProductStructureForSave(type.structure)
|
||||||
|
cfDefinitions.value = normalized?.customFields ?? []
|
||||||
})
|
})
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
|
||||||
customFieldInputs.value.every((field) => {
|
|
||||||
if (!field.required) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (field.type === 'boolean') {
|
|
||||||
return field.value === 'true' || field.value === 'false'
|
|
||||||
}
|
|
||||||
return field.value.trim().length > 0
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
canEdit.value &&
|
canEdit.value &&
|
||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
@@ -336,7 +356,8 @@ const submitCreation = async () => {
|
|||||||
const result = await createProduct(payload)
|
const result = await createProduct(payload)
|
||||||
if (result.success && result.data?.id) {
|
if (result.success && result.data?.id) {
|
||||||
const productId = result.data.id
|
const productId = result.data.id
|
||||||
const failedFields = await saveCustomFieldValues(result.data.id)
|
createdEntityId.value = productId
|
||||||
|
const failedFields = await saveAllCustomFields()
|
||||||
if (failedFields.length) {
|
if (failedFields.length) {
|
||||||
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||||
await router.replace(`/product/${result.data.id}?edit=true`)
|
await router.replace(`/product/${result.data.id}?edit=true`)
|
||||||
@@ -375,39 +396,6 @@ const submitCreation = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveCustomFieldValues = async (productId: string) => {
|
|
||||||
const failed: string[] = []
|
|
||||||
for (const field of customFieldInputs.value) {
|
|
||||||
if (!field.name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const value = field.value ?? ''
|
|
||||||
const metadata = field.customFieldId
|
|
||||||
? undefined
|
|
||||||
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
|
|
||||||
const result = await upsertCustomFieldValue(
|
|
||||||
field.customFieldId,
|
|
||||||
'product',
|
|
||||||
productId,
|
|
||||||
String(value ?? ''),
|
|
||||||
metadata,
|
|
||||||
)
|
|
||||||
if (!result.success) {
|
|
||||||
failed.push(field.name)
|
|
||||||
} else {
|
|
||||||
const createdValue = result.data
|
|
||||||
if (createdValue?.id) {
|
|
||||||
field.customFieldValueId = createdValue.id
|
|
||||||
}
|
|
||||||
const resolvedId = createdValue?.customField?.id || field.customFieldId
|
|
||||||
if (resolvedId) {
|
|
||||||
field.customFieldId = resolvedId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return failed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
|
||||||
watch(
|
watch(
|
||||||
() => creationForm.constructeurIds,
|
() => creationForm.constructeurIds,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type ComponentModelStructure,
|
type ComponentModelStructure,
|
||||||
type ComponentModelStructureNode,
|
type ComponentModelStructureNode,
|
||||||
} from '../types/inventory'
|
} from '../types/inventory'
|
||||||
|
import { mergeDefinitionsWithValues } from '../utils/customFields'
|
||||||
|
|
||||||
// Import for internal use in this file
|
// Import for internal use in this file
|
||||||
import { sanitizeCustomFields, sanitizePieces, sanitizeProducts, sanitizeSubcomponents } from './componentStructureSanitize'
|
import { sanitizeCustomFields, sanitizePieces, sanitizeProducts, sanitizeSubcomponents } from './componentStructureSanitize'
|
||||||
@@ -86,30 +87,22 @@ export const cloneStructure = (input: any): ComponentModelStructure => {
|
|||||||
export const normalizeStructureForEditor = (input: any): ComponentModelStructure => {
|
export const normalizeStructureForEditor = (input: any): ComponentModelStructure => {
|
||||||
const source = cloneStructure(input)
|
const source = cloneStructure(input)
|
||||||
|
|
||||||
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
|
const merged = mergeDefinitionsWithValues(source.customFields, [])
|
||||||
const customFields = sanitizedCustomFields.map((field) => {
|
const customFields: ComponentModelCustomField[] = merged.map((field) => ({
|
||||||
const options = Array.isArray(field.options) ? [...field.options] : []
|
name: field.name,
|
||||||
const optionsText = options.length ? options.join('\n') : ''
|
type: field.type as ComponentModelCustomField['type'],
|
||||||
const defaultValue =
|
required: field.required,
|
||||||
field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== ''
|
machineContextOnly: field.machineContextOnly,
|
||||||
? String(field.defaultValue)
|
options: field.options,
|
||||||
: null
|
defaultValue: field.defaultValue,
|
||||||
const copy: ComponentModelCustomField = {
|
optionsText: field.optionsText,
|
||||||
name: field.name,
|
id: field.customFieldId ?? undefined,
|
||||||
type: field.type,
|
customFieldId: field.customFieldId ?? undefined,
|
||||||
required: field.required,
|
orderIndex: field.orderIndex,
|
||||||
machineContextOnly: !!field.machineContextOnly,
|
}))
|
||||||
options,
|
|
||||||
defaultValue,
|
|
||||||
optionsText,
|
|
||||||
id: field.id,
|
|
||||||
customFieldId: field.customFieldId,
|
|
||||||
}
|
|
||||||
return copy
|
|
||||||
})
|
|
||||||
|
|
||||||
const result: ComponentModelStructure = {
|
const result: ComponentModelStructure = {
|
||||||
customFields: customFields as ComponentModelCustomField[],
|
customFields,
|
||||||
pieces: sanitizePieces(source.pieces),
|
pieces: sanitizePieces(source.pieces),
|
||||||
products: sanitizeProducts(source.products),
|
products: sanitizeProducts(source.products),
|
||||||
subcomponents: hydrateSubcomponents(source.subcomponents),
|
subcomponents: hydrateSubcomponents(source.subcomponents),
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface DataTableColumn {
|
|||||||
headerClass?: string
|
headerClass?: string
|
||||||
/** Width hint (e.g. 'w-24', 'min-w-[10rem]') */
|
/** Width hint (e.g. 'w-24', 'min-w-[10rem]') */
|
||||||
width?: string
|
width?: string
|
||||||
|
/** Inline min-width style (e.g. '120px', '8rem'). Only effective with fixedLayout. */
|
||||||
|
minWidth?: string
|
||||||
/** Text alignment: 'left' (default), 'center', 'right' */
|
/** Text alignment: 'left' (default), 'center', 'right' */
|
||||||
align?: 'left' | 'center' | 'right'
|
align?: 'left' | 'center' | 'right'
|
||||||
/** Hide on mobile (adds 'hidden sm:table-cell') */
|
/** Hide on mobile (adds 'hidden sm:table-cell') */
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
* copy of extractCollection (parsing hydra:member / member / data / array).
|
* copy of extractCollection (parsing hydra:member / member / data / array).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export function extractTotal(payload: unknown, fallbackLength: number): number {
|
||||||
|
const p = payload as Record<string, unknown> | null
|
||||||
|
if (typeof p?.totalItems === 'number') return p.totalItems
|
||||||
|
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
|
||||||
|
return fallbackLength
|
||||||
|
}
|
||||||
|
|
||||||
export function extractCollection<T = any>(payload: unknown): T[] {
|
export function extractCollection<T = any>(payload: unknown): T[] {
|
||||||
if (Array.isArray(payload)) return payload as T[]
|
if (Array.isArray(payload)) return payload as T[]
|
||||||
const p = payload as Record<string, unknown> | null
|
const p = payload as Record<string, unknown> | null
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
/**
|
|
||||||
* Custom field form normalization, merge, and persistence utilities.
|
|
||||||
*
|
|
||||||
* Extracted from pages/component/create.vue, component/[id]/edit.vue,
|
|
||||||
* pieces/create.vue, pieces/[id]/edit.vue, product/[id]/edit.vue.
|
|
||||||
*
|
|
||||||
* Every create/edit page was shipping its own copy of these helpers –
|
|
||||||
* this module unifies them behind a single, entity-agnostic API.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface CustomFieldInput {
|
|
||||||
id: string | null
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
required: boolean
|
|
||||||
options: string[]
|
|
||||||
value: string
|
|
||||||
customFieldId: string | null
|
|
||||||
customFieldValueId: string | null
|
|
||||||
orderIndex: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SaveCustomFieldDeps {
|
|
||||||
customFieldInputs: { value: CustomFieldInput[] }
|
|
||||||
upsertCustomFieldValue: (
|
|
||||||
definitionId: string | null,
|
|
||||||
entityType: string,
|
|
||||||
entityId: string,
|
|
||||||
value: string,
|
|
||||||
metadata?: Record<string, unknown>,
|
|
||||||
) => Promise<{ success: boolean; data?: any }>
|
|
||||||
updateCustomFieldValue: (
|
|
||||||
id: string,
|
|
||||||
payload: { value: string },
|
|
||||||
) => Promise<{ success: boolean }>
|
|
||||||
toast: { showError: (msg: string) => void }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Primitive helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const toFieldString = (value: unknown): string => {
|
|
||||||
if (value === null || value === undefined) return ''
|
|
||||||
if (typeof value === 'string') return value
|
|
||||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fieldKey = (field: CustomFieldInput, index: number): string =>
|
|
||||||
field.customFieldValueId || field.id || `${field.name}-${index}`
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Field resolution helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const resolveFieldName = (field: any): string => {
|
|
||||||
if (typeof field?.name === 'string' && field.name.trim()) return field.name.trim()
|
|
||||||
if (typeof field?.key === 'string' && field.key.trim()) return field.key.trim()
|
|
||||||
if (typeof field?.label === 'string' && field.label.trim()) return field.label.trim()
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export const resolveFieldType = (field: any): string => {
|
|
||||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
|
||||||
const rawType =
|
|
||||||
typeof field?.type === 'string'
|
|
||||||
? field.type
|
|
||||||
: typeof field?.value?.type === 'string'
|
|
||||||
? field.value.type
|
|
||||||
: ''
|
|
||||||
const value = rawType.toLowerCase()
|
|
||||||
return allowed.includes(value) ? value : 'text'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const resolveRequiredFlag = (field: any): boolean => {
|
|
||||||
if (typeof field?.required === 'boolean') return field.required
|
|
||||||
const nested = field?.value?.required
|
|
||||||
if (typeof nested === 'boolean') return nested
|
|
||||||
if (typeof nested === 'string') {
|
|
||||||
const normalized = nested.toLowerCase()
|
|
||||||
return normalized === 'true' || normalized === '1'
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const resolveOptions = (field: any): string[] => {
|
|
||||||
const sources = [field?.options, field?.value?.options, field?.value?.choices]
|
|
||||||
for (const source of sources) {
|
|
||||||
if (Array.isArray(source)) {
|
|
||||||
const mapped = source
|
|
||||||
.map((option: unknown) => {
|
|
||||||
if (option === null || option === undefined) return ''
|
|
||||||
if (typeof option === 'string') return option.trim()
|
|
||||||
if (typeof option === 'object') {
|
|
||||||
const record = (option || {}) as Record<string, unknown>
|
|
||||||
for (const key of ['value', 'label', 'name']) {
|
|
||||||
const candidate = record[key]
|
|
||||||
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fallback = String(option).trim()
|
|
||||||
return fallback === '[object Object]' ? '' : fallback
|
|
||||||
})
|
|
||||||
.filter((o) => o.length > 0)
|
|
||||||
if (mapped.length) return mapped
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export const resolveDefaultValue = (field: any): any => {
|
|
||||||
if (!field || typeof field !== 'object') return null
|
|
||||||
if (field.defaultValue !== undefined && field.defaultValue !== null) return field.defaultValue
|
|
||||||
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') return field.value
|
|
||||||
if (field.default !== undefined && field.default !== null) return field.default
|
|
||||||
if (field.value && typeof field.value === 'object') {
|
|
||||||
if (field.value.defaultValue !== undefined && field.value.defaultValue !== null) return field.value.defaultValue
|
|
||||||
if (field.value.value !== undefined && field.value.value !== null && typeof field.value.value !== 'object') return field.value.value
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatDefaultValue = (type: string, defaultValue: any): string => {
|
|
||||||
if (defaultValue === null || defaultValue === undefined) return ''
|
|
||||||
if (typeof defaultValue === 'object') {
|
|
||||||
if (defaultValue === null) return ''
|
|
||||||
if ('defaultValue' in (defaultValue as Record<string, any>)) {
|
|
||||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
|
|
||||||
}
|
|
||||||
if ('value' in (defaultValue as Record<string, any>)) {
|
|
||||||
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (type === 'boolean') {
|
|
||||||
const normalized = String(defaultValue).toLowerCase()
|
|
||||||
if (normalized === 'true' || normalized === '1') return 'true'
|
|
||||||
if (normalized === 'false' || normalized === '0') return 'false'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return String(defaultValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Normalize a single raw custom-field definition into CustomFieldInput
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
|
|
||||||
if (!rawField || typeof rawField !== 'object') return null
|
|
||||||
const name = resolveFieldName(rawField)
|
|
||||||
if (!name) return null
|
|
||||||
const type = resolveFieldType(rawField)
|
|
||||||
const required = resolveRequiredFlag(rawField)
|
|
||||||
const options = resolveOptions(rawField)
|
|
||||||
const defaultSource = resolveDefaultValue(rawField)
|
|
||||||
const value = formatDefaultValue(type, defaultSource)
|
|
||||||
const id = typeof rawField.id === 'string' ? rawField.id : null
|
|
||||||
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
|
|
||||||
const customFieldValueId = typeof rawField.customFieldValueId === 'string' ? rawField.customFieldValueId : null
|
|
||||||
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
|
|
||||||
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Normalize ALL custom-field definitions from a structure
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const normalizeCustomFieldInputs = (structure: any): CustomFieldInput[] => {
|
|
||||||
if (!structure || typeof structure !== 'object') return []
|
|
||||||
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
|
|
||||||
return fields
|
|
||||||
.map((field: any, index: number) => normalizeCustomField(field, index))
|
|
||||||
.filter((field: CustomFieldInput | null): field is CustomFieldInput => field !== null)
|
|
||||||
.sort((a: CustomFieldInput, b: CustomFieldInput) => a.orderIndex - b.orderIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Extract stored value from a persisted custom-field entry
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const extractStoredCustomFieldValue = (entry: any): any => {
|
|
||||||
if (entry === null || entry === undefined) return ''
|
|
||||||
if (typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean') return entry
|
|
||||||
if (typeof entry !== 'object') return String(entry)
|
|
||||||
|
|
||||||
const direct = entry.value
|
|
||||||
if (direct !== undefined && direct !== null) {
|
|
||||||
if (typeof direct === 'object') {
|
|
||||||
if (direct === null) return ''
|
|
||||||
if ('value' in direct && direct.value !== undefined && direct.value !== null) return direct.value
|
|
||||||
if ('defaultValue' in direct && direct.defaultValue !== undefined && direct.defaultValue !== null) return direct.defaultValue
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return direct
|
|
||||||
}
|
|
||||||
if (entry.defaultValue !== undefined && entry.defaultValue !== null) return entry.defaultValue
|
|
||||||
if (entry.customFieldValue?.value !== undefined && entry.customFieldValue?.value !== null) return entry.customFieldValue.value
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Build inputs for edit pages (merge definitions + stored values)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const buildCustomFieldInputs = (
|
|
||||||
structure: any,
|
|
||||||
values: any[] | null | undefined,
|
|
||||||
): CustomFieldInput[] => {
|
|
||||||
const definitions = normalizeCustomFieldInputs(structure)
|
|
||||||
const valueList = Array.isArray(values) ? values : []
|
|
||||||
|
|
||||||
const mapById = new Map<string, any>()
|
|
||||||
const mapByName = new Map<string, any>()
|
|
||||||
|
|
||||||
valueList.forEach((entry) => {
|
|
||||||
if (!entry || typeof entry !== 'object') return
|
|
||||||
const fieldId = entry.customField?.id || entry.customFieldId || null
|
|
||||||
if (fieldId) mapById.set(fieldId, entry)
|
|
||||||
const fieldName = entry.customField?.name || entry.name || entry.key || null
|
|
||||||
if (fieldName) mapByName.set(fieldName, entry)
|
|
||||||
})
|
|
||||||
|
|
||||||
const matchedIds = new Set<string>()
|
|
||||||
const matchedNames = new Set<string>()
|
|
||||||
|
|
||||||
const result = definitions
|
|
||||||
.map((definition) => {
|
|
||||||
const definitionId = definition.customFieldId || definition.id || null
|
|
||||||
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
|
|
||||||
|
|
||||||
if (!matched) {
|
|
||||||
return {
|
|
||||||
...definition,
|
|
||||||
customFieldId: definition.customFieldId || definition.id,
|
|
||||||
customFieldValueId: null,
|
|
||||||
orderIndex: definition.orderIndex,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedFieldId = matched.customField?.id || matched.customFieldId || null
|
|
||||||
if (matchedFieldId) matchedIds.add(matchedFieldId)
|
|
||||||
const matchedFieldName = matched.customField?.name || matched.name || null
|
|
||||||
if (matchedFieldName) matchedNames.add(matchedFieldName)
|
|
||||||
|
|
||||||
const resolvedValue = extractStoredCustomFieldValue(matched)
|
|
||||||
return {
|
|
||||||
...definition,
|
|
||||||
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
|
|
||||||
customFieldValueId: matched.id ?? null,
|
|
||||||
value: formatDefaultValue(definition.type, resolvedValue),
|
|
||||||
orderIndex: Math.min(
|
|
||||||
definition.orderIndex ?? 0,
|
|
||||||
typeof matched.customField?.orderIndex === 'number'
|
|
||||||
? matched.customField.orderIndex
|
|
||||||
: definition.orderIndex ?? 0,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Include values with embedded definitions that didn't match any structure definition
|
|
||||||
valueList.forEach((entry, index) => {
|
|
||||||
if (!entry || typeof entry !== 'object') return
|
|
||||||
const cf = entry.customField
|
|
||||||
if (!cf || typeof cf !== 'object') return
|
|
||||||
const fieldId = cf.id || entry.customFieldId || null
|
|
||||||
const fieldName = cf.name || entry.name || null
|
|
||||||
if (fieldId && matchedIds.has(fieldId)) return
|
|
||||||
if (fieldName && matchedNames.has(fieldName)) return
|
|
||||||
|
|
||||||
const name = resolveFieldName(cf)
|
|
||||||
if (!name) return
|
|
||||||
|
|
||||||
const type = resolveFieldType(cf)
|
|
||||||
const resolvedValue = extractStoredCustomFieldValue(entry)
|
|
||||||
result.push({
|
|
||||||
id: fieldId,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
required: resolveRequiredFlag(cf),
|
|
||||||
options: resolveOptions(cf),
|
|
||||||
value: formatDefaultValue(type, resolvedValue),
|
|
||||||
customFieldId: fieldId,
|
|
||||||
customFieldValueId: entry.id ?? null,
|
|
||||||
orderIndex: typeof cf.orderIndex === 'number' ? cf.orderIndex : definitions.length + index,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Validation helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const buildCustomFieldMetadata = (field: CustomFieldInput): Record<string, unknown> => ({
|
|
||||||
customFieldName: field.name,
|
|
||||||
customFieldType: field.type,
|
|
||||||
customFieldRequired: field.required,
|
|
||||||
customFieldOptions: field.options,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const shouldPersistField = (field: CustomFieldInput): boolean => {
|
|
||||||
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
|
|
||||||
return toFieldString(field.value).trim() !== ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatValueForPersistence = (field: CustomFieldInput): string => {
|
|
||||||
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
|
|
||||||
return toFieldString(field.value).trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const requiredCustomFieldsFilled = (inputs: CustomFieldInput[]): boolean =>
|
|
||||||
inputs.every((field) => {
|
|
||||||
if (!field.required) return true
|
|
||||||
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
|
|
||||||
return toFieldString(field.value).trim() !== ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Persistence
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save custom-field values for an entity.
|
|
||||||
*
|
|
||||||
* @param entityType - API entity slug ('composant' | 'piece' | 'product')
|
|
||||||
* @param entityId - ID of the created/updated entity
|
|
||||||
* @param definitionSources - arrays of raw definition objects to build a name→id map
|
|
||||||
* @param deps - injected composable references
|
|
||||||
* @returns list of field names that failed to save (empty = all OK)
|
|
||||||
*/
|
|
||||||
export const saveCustomFieldValues = async (
|
|
||||||
entityType: string,
|
|
||||||
entityId: string,
|
|
||||||
definitionSources: any[][],
|
|
||||||
deps: SaveCustomFieldDeps,
|
|
||||||
): Promise<string[]> => {
|
|
||||||
if (!entityId) return []
|
|
||||||
|
|
||||||
const definitionMap = new Map<string, string>()
|
|
||||||
const registerDefinitions = (fields: any[]) => {
|
|
||||||
if (!Array.isArray(fields)) return
|
|
||||||
fields.forEach((field) => {
|
|
||||||
if (!field || typeof field !== 'object') return
|
|
||||||
const name = typeof field.name === 'string' ? field.name : null
|
|
||||||
const id = typeof field.id === 'string' ? field.id : null
|
|
||||||
if (name && id && !definitionMap.has(name)) definitionMap.set(name, id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
definitionSources.forEach(registerDefinitions)
|
|
||||||
|
|
||||||
const resolveDefinitionId = (field: CustomFieldInput) => {
|
|
||||||
if (field.customFieldId) return field.customFieldId
|
|
||||||
if (field.id) return field.id
|
|
||||||
return definitionMap.get(field.name) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
const failed: string[] = []
|
|
||||||
|
|
||||||
for (const field of deps.customFieldInputs.value) {
|
|
||||||
if (!shouldPersistField(field)) continue
|
|
||||||
|
|
||||||
const definitionId = resolveDefinitionId(field)
|
|
||||||
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
|
|
||||||
const value = formatValueForPersistence(field)
|
|
||||||
|
|
||||||
if (field.customFieldValueId) {
|
|
||||||
const result = await deps.updateCustomFieldValue(field.customFieldValueId, { value })
|
|
||||||
if (!result.success) {
|
|
||||||
deps.toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
|
|
||||||
failed.push(field.name)
|
|
||||||
} else if (definitionId && !field.customFieldId) {
|
|
||||||
field.customFieldId = definitionId
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await deps.upsertCustomFieldValue(
|
|
||||||
definitionId,
|
|
||||||
entityType,
|
|
||||||
entityId,
|
|
||||||
value,
|
|
||||||
metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
deps.toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
|
|
||||||
failed.push(field.name)
|
|
||||||
} else {
|
|
||||||
const createdValue = result.data
|
|
||||||
if (createdValue?.id) field.customFieldValueId = createdValue.id
|
|
||||||
const resolvedId = createdValue?.customField?.id || definitionId
|
|
||||||
if (resolvedId) field.customFieldId = resolvedId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return failed
|
|
||||||
}
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
/**
|
|
||||||
* Custom field normalization, merging and display utilities.
|
|
||||||
*
|
|
||||||
* Extracted from pages/machine/[id].vue to be reusable across
|
|
||||||
* machine detail, component, piece and product views.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Primitive helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const coerceValueForType = (type: string, rawValue: unknown): string => {
|
|
||||||
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (type === 'boolean') {
|
|
||||||
const normalized = String(rawValue).toLowerCase()
|
|
||||||
if (normalized === 'true' || normalized === '1') return 'true'
|
|
||||||
if (normalized === 'false' || normalized === '0') return 'false'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return String(rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatCustomFieldValue = (field: Record<string, unknown> | null | undefined): string => {
|
|
||||||
if (!field) return 'Non défini'
|
|
||||||
|
|
||||||
const value = (field.value ?? field.defaultValue ?? '') as string
|
|
||||||
if (value === '' || value === null || value === undefined) return 'Non défini'
|
|
||||||
|
|
||||||
if (field.type === 'boolean') {
|
|
||||||
const normalized = String(value).toLowerCase()
|
|
||||||
if (normalized === 'true' || normalized === '1') return 'Oui'
|
|
||||||
if (normalized === 'false' || normalized === '0') return 'Non'
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const shouldDisplayCustomField = (field: Record<string, unknown> | null | undefined): boolean => {
|
|
||||||
if (!field) return false
|
|
||||||
if (field.readOnly) return true
|
|
||||||
if (field.type === 'boolean') return field.value !== undefined && field.value !== null
|
|
||||||
|
|
||||||
const value = field.value
|
|
||||||
if (value === null || value === undefined) return false
|
|
||||||
if (typeof value === 'string') return value.trim().length > 0
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Definition extraction helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const extractDefinitionName = (definition: Record<string, unknown> = {}): string => {
|
|
||||||
if (typeof definition?.name === 'string' && (definition.name as string).trim()) {
|
|
||||||
return (definition.name as string).trim()
|
|
||||||
}
|
|
||||||
if (typeof definition?.key === 'string' && (definition.key as string).trim()) {
|
|
||||||
return (definition.key as string).trim()
|
|
||||||
}
|
|
||||||
if (typeof definition?.label === 'string' && (definition.label as string).trim()) {
|
|
||||||
return (definition.label as string).trim()
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export const extractDefinitionType = (
|
|
||||||
definition: Record<string, unknown> = {},
|
|
||||||
fallback = 'text',
|
|
||||||
): string => {
|
|
||||||
const allowed = ['text', 'number', 'select', 'boolean', 'date']
|
|
||||||
const rawType =
|
|
||||||
typeof definition?.type === 'string'
|
|
||||||
? definition.type
|
|
||||||
: typeof (definition?.value as Record<string, unknown>)?.type === 'string'
|
|
||||||
? (definition.value as Record<string, unknown>).type as string
|
|
||||||
: typeof fallback === 'string'
|
|
||||||
? fallback
|
|
||||||
: 'text'
|
|
||||||
const normalized = (rawType as string).toLowerCase()
|
|
||||||
return allowed.includes(normalized) ? normalized : 'text'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const extractDefinitionRequired = (
|
|
||||||
definition: Record<string, unknown> = {},
|
|
||||||
fallback = false,
|
|
||||||
): boolean => {
|
|
||||||
if (typeof definition?.required === 'boolean') return definition.required
|
|
||||||
const nested = (definition?.value as Record<string, unknown>)?.required
|
|
||||||
if (typeof nested === 'boolean') return nested
|
|
||||||
if (typeof nested === 'string') {
|
|
||||||
const normalized = nested.toLowerCase()
|
|
||||||
if (normalized === 'true' || normalized === '1') return true
|
|
||||||
if (normalized === 'false' || normalized === '0') return false
|
|
||||||
}
|
|
||||||
return !!fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractOptionList = (input: unknown): string[] | undefined => {
|
|
||||||
if (!Array.isArray(input)) return undefined
|
|
||||||
const mapped = input
|
|
||||||
.map((option) => {
|
|
||||||
if (option === null || option === undefined) return ''
|
|
||||||
if (typeof option === 'string') return option.trim()
|
|
||||||
if (typeof option === 'object') {
|
|
||||||
const record = (option || {}) as Record<string, unknown>
|
|
||||||
for (const key of ['value', 'label', 'name']) {
|
|
||||||
const candidate = record[key]
|
|
||||||
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fallback = String(option).trim()
|
|
||||||
return fallback === '[object Object]' ? '' : fallback
|
|
||||||
})
|
|
||||||
.filter((option) => option.length > 0)
|
|
||||||
return mapped.length ? mapped : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export const extractDefinitionOptions = (definition: Record<string, unknown> = {}): string[] => {
|
|
||||||
const sources = [
|
|
||||||
definition?.options,
|
|
||||||
(definition?.value as Record<string, unknown>)?.options,
|
|
||||||
(definition?.value as Record<string, unknown>)?.choices,
|
|
||||||
]
|
|
||||||
for (const source of sources) {
|
|
||||||
const list = extractOptionList(source)
|
|
||||||
if (list) return list
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export const extractDefinitionDefaultValue = (definition: Record<string, unknown> = {}): unknown => {
|
|
||||||
const candidates = [
|
|
||||||
definition?.defaultValue,
|
|
||||||
(definition?.value as Record<string, unknown>)?.defaultValue,
|
|
||||||
(definition?.value as Record<string, unknown>)?.value,
|
|
||||||
definition?.value,
|
|
||||||
definition?.default,
|
|
||||||
]
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (candidate === undefined || candidate === null || candidate === '') continue
|
|
||||||
if (typeof candidate === 'object') {
|
|
||||||
if (candidate === null) continue
|
|
||||||
const nestedDefault =
|
|
||||||
(candidate as Record<string, unknown>).defaultValue !== undefined &&
|
|
||||||
(candidate as Record<string, unknown>).defaultValue !== null
|
|
||||||
? (candidate as Record<string, unknown>).defaultValue
|
|
||||||
: (candidate as Record<string, unknown>).value
|
|
||||||
if (nestedDefault !== undefined && nestedDefault !== null && nestedDefault !== '') {
|
|
||||||
return nestedDefault
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Normalization
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface NormalizedCustomFieldDefinition {
|
|
||||||
id?: string
|
|
||||||
customFieldId?: string
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
required: boolean
|
|
||||||
options: string[]
|
|
||||||
defaultValue?: unknown
|
|
||||||
readOnly: boolean
|
|
||||||
orderIndex: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedCustomFieldEntry {
|
|
||||||
customFieldValueId: unknown
|
|
||||||
id: string | undefined
|
|
||||||
customFieldId: string | null
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
required: boolean
|
|
||||||
options: string[]
|
|
||||||
optionsText: string
|
|
||||||
defaultValue: unknown
|
|
||||||
value: string
|
|
||||||
readOnly: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const normalizeCustomFieldDefinitionEntry = (
|
|
||||||
definition: Record<string, unknown> = {},
|
|
||||||
fallbackIndex = 0,
|
|
||||||
): NormalizedCustomFieldDefinition | null => {
|
|
||||||
const name = extractDefinitionName(definition)
|
|
||||||
if (!name) return null
|
|
||||||
const type = extractDefinitionType(definition)
|
|
||||||
const required = extractDefinitionRequired(definition)
|
|
||||||
const options = extractDefinitionOptions(definition)
|
|
||||||
const defaultValue = extractDefinitionDefaultValue(definition)
|
|
||||||
const id = typeof definition?.id === 'string' ? definition.id : undefined
|
|
||||||
const customFieldId = typeof definition?.customFieldId === 'string' ? definition.customFieldId : id
|
|
||||||
const orderIndex = typeof definition?.orderIndex === 'number' ? definition.orderIndex : fallbackIndex
|
|
||||||
return { id, customFieldId, name, type, required, options, defaultValue, readOnly: !!definition?.readOnly, orderIndex }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const normalizeExistingCustomFieldDefinitions = (
|
|
||||||
fields: unknown,
|
|
||||||
): NormalizedCustomFieldDefinition[] => {
|
|
||||||
if (!Array.isArray(fields)) return []
|
|
||||||
return fields
|
|
||||||
.map((field, index) => normalizeCustomFieldDefinitionEntry(field as Record<string, unknown>, index))
|
|
||||||
.filter((d): d is NormalizedCustomFieldDefinition => d !== null)
|
|
||||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Custom field value normalization
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const normalizeCustomFieldValueEntry = (entry: Record<string, unknown> = {}): Record<string, unknown> | null => {
|
|
||||||
if (!entry || typeof entry !== 'object') return null
|
|
||||||
const normalizedDefinition = normalizeCustomFieldDefinitionEntry(entry)
|
|
||||||
if (!normalizedDefinition) return null
|
|
||||||
|
|
||||||
const value = coerceValueForType(
|
|
||||||
normalizedDefinition.type,
|
|
||||||
(entry?.value ?? entry?.defaultValue ?? normalizedDefinition.defaultValue ?? '') as string,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: (entry?.customFieldValueId ?? entry?.id ?? null) as string | null,
|
|
||||||
customFieldId:
|
|
||||||
(entry?.customFieldId ?? normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null) as string | null,
|
|
||||||
customField: {
|
|
||||||
id: normalizedDefinition.id ?? normalizedDefinition.customFieldId ?? null,
|
|
||||||
name: normalizedDefinition.name,
|
|
||||||
type: normalizedDefinition.type,
|
|
||||||
required: normalizedDefinition.required,
|
|
||||||
options: normalizedDefinition.options,
|
|
||||||
defaultValue: normalizedDefinition.defaultValue ?? '',
|
|
||||||
},
|
|
||||||
value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Merge & dedup
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const mergeCustomFieldValuesWithDefinitions = (
|
|
||||||
valueEntries: Record<string, unknown>[] = [],
|
|
||||||
...definitionSources: unknown[][]
|
|
||||||
): Record<string, unknown>[] => {
|
|
||||||
const normalizedValues: Record<string, unknown>[] = (Array.isArray(valueEntries) ? valueEntries : [])
|
|
||||||
.map((entry): Record<string, unknown> | null => {
|
|
||||||
if (!entry || typeof entry !== 'object') return null
|
|
||||||
const normalizedDefinition = normalizeCustomFieldDefinitionEntry(
|
|
||||||
((entry as Record<string, unknown>).customField || entry) as Record<string, unknown>,
|
|
||||||
)
|
|
||||||
if (!normalizedDefinition) return null
|
|
||||||
|
|
||||||
const value = coerceValueForType(
|
|
||||||
normalizedDefinition.type,
|
|
||||||
((entry as Record<string, unknown>)?.value ??
|
|
||||||
(entry as Record<string, unknown>)?.defaultValue ??
|
|
||||||
normalizedDefinition.defaultValue ??
|
|
||||||
'') as string,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
customFieldValueId: (entry as Record<string, unknown>)?.id ?? (entry as Record<string, unknown>)?.customFieldValueId ?? null,
|
|
||||||
id: normalizedDefinition.id,
|
|
||||||
customFieldId: normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null,
|
|
||||||
name: normalizedDefinition.name,
|
|
||||||
type: normalizedDefinition.type,
|
|
||||||
required: normalizedDefinition.required,
|
|
||||||
options: normalizedDefinition.options,
|
|
||||||
optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '',
|
|
||||||
defaultValue: normalizedDefinition.defaultValue ?? '',
|
|
||||||
value,
|
|
||||||
readOnly: !!(entry as Record<string, unknown>)?.readOnly,
|
|
||||||
} as Record<string, unknown>
|
|
||||||
})
|
|
||||||
.filter((entry): entry is Record<string, unknown> => entry !== null)
|
|
||||||
|
|
||||||
const result = [...normalizedValues]
|
|
||||||
const keyFor = (item: Record<string, unknown>) => (item?.id as string) ?? `${item?.name ?? ''}::${item?.type ?? ''}`
|
|
||||||
const existingMap = new Map<string, Record<string, unknown>>()
|
|
||||||
|
|
||||||
result.forEach((item) => {
|
|
||||||
const key = keyFor(item)
|
|
||||||
if (key) existingMap.set(key, item)
|
|
||||||
const fallbackKey = item?.name ? `${item.name}::${item.type ?? ''}` : null
|
|
||||||
if (fallbackKey) existingMap.set(fallbackKey, item)
|
|
||||||
})
|
|
||||||
|
|
||||||
const definitions = definitionSources
|
|
||||||
.flatMap((source) => (Array.isArray(source) ? source : []))
|
|
||||||
.map((definition) => normalizeCustomFieldDefinitionEntry(definition as Record<string, unknown>))
|
|
||||||
.filter((d): d is NormalizedCustomFieldDefinition => d !== null)
|
|
||||||
|
|
||||||
definitions.forEach((normalizedDefinition) => {
|
|
||||||
const key = normalizedDefinition.id ?? `${normalizedDefinition.name}::${normalizedDefinition.type}`
|
|
||||||
if (!key) return
|
|
||||||
|
|
||||||
if (normalizedDefinition.id) {
|
|
||||||
const fallbackKey = `${normalizedDefinition.name}::${normalizedDefinition.type}`
|
|
||||||
if (existingMap.has(fallbackKey)) {
|
|
||||||
const existingFallback = existingMap.get(fallbackKey)
|
|
||||||
if (existingFallback) {
|
|
||||||
existingFallback.id = existingFallback.id || normalizedDefinition.id
|
|
||||||
existingFallback.customFieldId = normalizedDefinition.id
|
|
||||||
existingFallback.readOnly = (existingFallback.readOnly as boolean) && normalizedDefinition.readOnly
|
|
||||||
existingMap.delete(fallbackKey)
|
|
||||||
existingMap.set(normalizedDefinition.id, existingFallback)
|
|
||||||
existingMap.set(fallbackKey, existingFallback)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing =
|
|
||||||
existingMap.get(key) ||
|
|
||||||
(normalizedDefinition.name ? existingMap.get(`${normalizedDefinition.name}::${normalizedDefinition.type}`) : null)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
existing.name = existing.name || normalizedDefinition.name
|
|
||||||
existing.type = existing.type || normalizedDefinition.type
|
|
||||||
existing.required = (existing.required as boolean) || normalizedDefinition.required
|
|
||||||
if (!(existing.options as string[])?.length && normalizedDefinition.options?.length) {
|
|
||||||
existing.options = normalizedDefinition.options
|
|
||||||
}
|
|
||||||
if (!existing.defaultValue && normalizedDefinition.defaultValue) {
|
|
||||||
existing.defaultValue = String(normalizedDefinition.defaultValue)
|
|
||||||
if (!existing.value) {
|
|
||||||
existing.value = coerceValueForType(existing.type as string, normalizedDefinition.defaultValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
existing.customFieldId = existing.customFieldId || normalizedDefinition.id
|
|
||||||
existing.readOnly = (existing.readOnly as boolean) && normalizedDefinition.readOnly
|
|
||||||
if (!existing.optionsText && normalizedDefinition.options?.length) {
|
|
||||||
existing.optionsText = normalizedDefinition.options.join('\n')
|
|
||||||
}
|
|
||||||
if (normalizedDefinition.id) existingMap.set(normalizedDefinition.id, existing)
|
|
||||||
if (normalizedDefinition.name) {
|
|
||||||
existingMap.set(`${normalizedDefinition.name}::${normalizedDefinition.type}`, existing)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry: Record<string, unknown> = {
|
|
||||||
customFieldValueId: null,
|
|
||||||
id: normalizedDefinition.id,
|
|
||||||
customFieldId: normalizedDefinition.id,
|
|
||||||
name: normalizedDefinition.name,
|
|
||||||
type: normalizedDefinition.type,
|
|
||||||
required: normalizedDefinition.required,
|
|
||||||
options: normalizedDefinition.options,
|
|
||||||
optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '',
|
|
||||||
defaultValue: normalizedDefinition.defaultValue ?? '',
|
|
||||||
value: coerceValueForType(normalizedDefinition.type, (normalizedDefinition.defaultValue ?? '') as string),
|
|
||||||
readOnly: false,
|
|
||||||
}
|
|
||||||
result.push(entry)
|
|
||||||
existingMap.set(key, entry)
|
|
||||||
const fallbackKey = entry.name ? `${entry.name}::${entry.type}` : null
|
|
||||||
if (fallbackKey) existingMap.set(fallbackKey, entry)
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dedupeCustomFieldEntries = (fields: Record<string, unknown>[]): Record<string, unknown>[] => {
|
|
||||||
if (!Array.isArray(fields) || fields.length <= 1) {
|
|
||||||
return Array.isArray(fields) ? fields : []
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const result: Record<string, unknown>[] = []
|
|
||||||
|
|
||||||
for (const field of fields) {
|
|
||||||
if (!field) continue
|
|
||||||
|
|
||||||
field.type = field.type || 'text'
|
|
||||||
|
|
||||||
let normalizedName = typeof field.name === 'string' ? (field.name as string).trim() : ''
|
|
||||||
|
|
||||||
if (!normalizedName && (field.customField as Record<string, unknown>)?.name) {
|
|
||||||
normalizedName = String((field.customField as Record<string, unknown>).name).trim()
|
|
||||||
field.name = normalizedName
|
|
||||||
} else if (typeof field.name === 'string') {
|
|
||||||
field.name = normalizedName
|
|
||||||
}
|
|
||||||
|
|
||||||
const key =
|
|
||||||
(field.customFieldId as string) ||
|
|
||||||
(field.id as string) ||
|
|
||||||
(normalizedName ? `${normalizedName}::${field.type || 'text'}` : null)
|
|
||||||
|
|
||||||
if (!key && !normalizedName) continue
|
|
||||||
if (key && seen.has(key)) continue
|
|
||||||
if (!normalizedName) continue
|
|
||||||
|
|
||||||
if (key) seen.add(key)
|
|
||||||
if (normalizedName) seen.add(`${normalizedName}::${field.type || 'text'}`)
|
|
||||||
result.push(field)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Summarize for display
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const summarizeCustomFields = (
|
|
||||||
fields: Record<string, unknown>[] = [],
|
|
||||||
): { key: string; label: string; value: string }[] => {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
return fields
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => {
|
|
||||||
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
|
|
||||||
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
|
|
||||||
return (left as number) - (right as number)
|
|
||||||
})
|
|
||||||
.filter(shouldDisplayCustomField)
|
|
||||||
.filter((field) => {
|
|
||||||
const key = (field.customFieldId || field.id || field.name) as string
|
|
||||||
if (!key) return true
|
|
||||||
if (seen.has(key)) return false
|
|
||||||
seen.add(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map((field, index) => ({
|
|
||||||
key: ((field.customFieldId || field.id || field.name) as string) || `custom-field-${index}`,
|
|
||||||
label: (field.name as string) || 'Champ',
|
|
||||||
value: formatCustomFieldValue(field),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
305
frontend/app/shared/utils/customFields.ts
Normal file
305
frontend/app/shared/utils/customFields.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* Unified custom field types and pure helpers.
|
||||||
|
*
|
||||||
|
* Replaces: entityCustomFieldLogic.ts, customFieldUtils.ts, customFieldFormUtils.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A custom field definition (from ModelType structure or CustomField entity) */
|
||||||
|
export interface CustomFieldDefinition {
|
||||||
|
id: string | null
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
required: boolean
|
||||||
|
options: string[]
|
||||||
|
defaultValue: string | null
|
||||||
|
orderIndex: number
|
||||||
|
machineContextOnly: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A persisted custom field value (from CustomFieldValue entity via API) */
|
||||||
|
export interface CustomFieldValue {
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
customField: CustomFieldDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merged definition + value for form display and editing */
|
||||||
|
export interface CustomFieldInput {
|
||||||
|
customFieldId: string | null
|
||||||
|
customFieldValueId: string | null
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
required: boolean
|
||||||
|
options: string[]
|
||||||
|
defaultValue: string | null
|
||||||
|
orderIndex: number
|
||||||
|
machineContextOnly: boolean
|
||||||
|
value: string
|
||||||
|
readOnly?: boolean
|
||||||
|
/** options joined by newline — used by category editor textareas (v-model) */
|
||||||
|
optionsText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Normalization — accept any shape, return canonical types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = ['text', 'number', 'select', 'boolean', 'date'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize any raw field definition object into a CustomFieldDefinition.
|
||||||
|
* Handles both standard `{name, type}` and legacy `{key, value: {type}}` formats.
|
||||||
|
*/
|
||||||
|
export function normalizeDefinition(raw: any, fallbackIndex = 0): CustomFieldDefinition | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
|
||||||
|
// Resolve name: standard → legacy key → label
|
||||||
|
const name = (
|
||||||
|
typeof raw.name === 'string' ? raw.name.trim()
|
||||||
|
: typeof raw.key === 'string' ? raw.key.trim()
|
||||||
|
: typeof raw.label === 'string' ? raw.label.trim()
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
if (!name) return null
|
||||||
|
|
||||||
|
// Resolve type: standard → nested in value → fallback
|
||||||
|
const rawType = (
|
||||||
|
typeof raw.type === 'string' ? raw.type
|
||||||
|
: typeof raw.value?.type === 'string' ? raw.value.type
|
||||||
|
: 'text'
|
||||||
|
).toLowerCase()
|
||||||
|
const type = ALLOWED_TYPES.includes(rawType as any) ? rawType : 'text'
|
||||||
|
|
||||||
|
// Resolve required
|
||||||
|
const required = typeof raw.required === 'boolean' ? raw.required
|
||||||
|
: typeof raw.value?.required === 'boolean' ? raw.value.required
|
||||||
|
: false
|
||||||
|
|
||||||
|
// Resolve options
|
||||||
|
const optionSource = Array.isArray(raw.options) ? raw.options
|
||||||
|
: Array.isArray(raw.value?.options) ? raw.value.options
|
||||||
|
: []
|
||||||
|
const options = optionSource
|
||||||
|
.map((o: any) => typeof o === 'string' ? o.trim() : typeof o?.value === 'string' ? o.value.trim() : String(o ?? '').trim())
|
||||||
|
.filter((o: string) => o.length > 0 && o !== '[object Object]')
|
||||||
|
|
||||||
|
// Resolve defaultValue
|
||||||
|
const dv = raw.defaultValue ?? raw.value?.defaultValue ?? null
|
||||||
|
const defaultValue = dv !== null && dv !== undefined && dv !== '' ? String(dv) : null
|
||||||
|
|
||||||
|
// Resolve orderIndex
|
||||||
|
const orderIndex = typeof raw.orderIndex === 'number' ? raw.orderIndex : fallbackIndex
|
||||||
|
|
||||||
|
// Resolve machineContextOnly
|
||||||
|
const machineContextOnly = !!raw.machineContextOnly
|
||||||
|
|
||||||
|
// Resolve id
|
||||||
|
const id = typeof raw.id === 'string' ? raw.id
|
||||||
|
: typeof raw.customFieldId === 'string' ? raw.customFieldId
|
||||||
|
: null
|
||||||
|
|
||||||
|
return { id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw value entry into a CustomFieldValue.
|
||||||
|
* Accepts the API format: `{ id, value, customField: {...} }`
|
||||||
|
*/
|
||||||
|
export function normalizeValue(raw: any): CustomFieldValue | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const cf = raw.customField
|
||||||
|
const definition = normalizeDefinition(cf)
|
||||||
|
if (!definition) return null
|
||||||
|
const id = typeof raw.id === 'string' ? raw.id : ''
|
||||||
|
const value = raw.value !== null && raw.value !== undefined ? String(raw.value) : ''
|
||||||
|
return { id, value, customField: definition }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an array of raw definitions into CustomFieldDefinition[].
|
||||||
|
*/
|
||||||
|
export function normalizeDefinitions(raw: any): CustomFieldDefinition[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw
|
||||||
|
.map((item: any, i: number) => normalizeDefinition(item, i))
|
||||||
|
.filter((d: any): d is CustomFieldDefinition => d !== null)
|
||||||
|
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an array of raw values into CustomFieldValue[].
|
||||||
|
*/
|
||||||
|
export function normalizeValues(raw: any): CustomFieldValue[] {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
return raw
|
||||||
|
.map((item: any) => normalizeValue(item))
|
||||||
|
.filter((v: any): v is CustomFieldValue => v !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Merge — THE one merge function
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge definitions from a ModelType with persisted values from an entity.
|
||||||
|
* Returns a CustomFieldInput[] ready for form display.
|
||||||
|
*
|
||||||
|
* Match strategy: by customField.id first, then by name (case-sensitive).
|
||||||
|
* When no value exists for a definition, uses defaultValue as initial value.
|
||||||
|
*/
|
||||||
|
export function mergeDefinitionsWithValues(
|
||||||
|
rawDefinitions: any,
|
||||||
|
rawValues: any,
|
||||||
|
): CustomFieldInput[] {
|
||||||
|
const definitions = normalizeDefinitions(rawDefinitions)
|
||||||
|
const values = normalizeValues(rawValues)
|
||||||
|
|
||||||
|
// Build lookup maps for values
|
||||||
|
const valueById = new Map<string, CustomFieldValue>()
|
||||||
|
const valueByName = new Map<string, CustomFieldValue>()
|
||||||
|
for (const v of values) {
|
||||||
|
if (v.customField.id) valueById.set(v.customField.id, v)
|
||||||
|
valueByName.set(v.customField.name, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedValueIds = new Set<string>()
|
||||||
|
const matchedNames = new Set<string>()
|
||||||
|
|
||||||
|
// 1. Map definitions to inputs, matching values
|
||||||
|
const result: CustomFieldInput[] = definitions.map((def) => {
|
||||||
|
const matched = (def.id ? valueById.get(def.id) : undefined) ?? valueByName.get(def.name)
|
||||||
|
|
||||||
|
const optionsText = def.options.length ? def.options.join('\n') : undefined
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
if (matched.id) matchedValueIds.add(matched.id)
|
||||||
|
matchedNames.add(def.name)
|
||||||
|
return {
|
||||||
|
customFieldId: def.id,
|
||||||
|
customFieldValueId: matched.id || null,
|
||||||
|
name: def.name,
|
||||||
|
type: def.type,
|
||||||
|
required: def.required,
|
||||||
|
options: def.options,
|
||||||
|
defaultValue: def.defaultValue,
|
||||||
|
orderIndex: def.orderIndex,
|
||||||
|
machineContextOnly: def.machineContextOnly,
|
||||||
|
value: matched.value,
|
||||||
|
optionsText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No value found — use defaultValue
|
||||||
|
return {
|
||||||
|
customFieldId: def.id,
|
||||||
|
customFieldValueId: null,
|
||||||
|
name: def.name,
|
||||||
|
type: def.type,
|
||||||
|
required: def.required,
|
||||||
|
options: def.options,
|
||||||
|
defaultValue: def.defaultValue,
|
||||||
|
orderIndex: def.orderIndex,
|
||||||
|
machineContextOnly: def.machineContextOnly,
|
||||||
|
value: def.defaultValue ?? '',
|
||||||
|
optionsText,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Add orphan values (have a value but no matching definition)
|
||||||
|
for (const v of values) {
|
||||||
|
if (matchedValueIds.has(v.id)) continue
|
||||||
|
if (matchedNames.has(v.customField.name)) continue
|
||||||
|
|
||||||
|
const orphanOptionsText = v.customField.options.length ? v.customField.options.join('\n') : undefined
|
||||||
|
result.push({
|
||||||
|
customFieldId: v.customField.id,
|
||||||
|
customFieldValueId: v.id || null,
|
||||||
|
name: v.customField.name,
|
||||||
|
type: v.customField.type,
|
||||||
|
required: v.customField.required,
|
||||||
|
options: v.customField.options,
|
||||||
|
defaultValue: v.customField.defaultValue,
|
||||||
|
orderIndex: v.customField.orderIndex,
|
||||||
|
machineContextOnly: v.customField.machineContextOnly,
|
||||||
|
value: v.value,
|
||||||
|
optionsText: orphanOptionsText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filter & sort
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Filter fields by context: standalone hides machineContextOnly, machine shows only machineContextOnly */
|
||||||
|
export function filterByContext(
|
||||||
|
fields: CustomFieldInput[],
|
||||||
|
context: 'standalone' | 'machine',
|
||||||
|
): CustomFieldInput[] {
|
||||||
|
if (context === 'machine') return fields.filter((f) => f.machineContextOnly)
|
||||||
|
return fields.filter((f) => !f.machineContextOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort fields by orderIndex */
|
||||||
|
export function sortByOrder(fields: CustomFieldInput[]): CustomFieldInput[] {
|
||||||
|
return [...fields].sort((a, b) => a.orderIndex - b.orderIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Display helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Format a field value for display (e.g. boolean → Oui/Non) */
|
||||||
|
export function formatValueForDisplay(field: CustomFieldInput): string {
|
||||||
|
const raw = field.value ?? ''
|
||||||
|
if (field.type === 'boolean') {
|
||||||
|
const normalized = String(raw).toLowerCase()
|
||||||
|
if (normalized === 'true' || normalized === '1') return 'Oui'
|
||||||
|
if (normalized === 'false' || normalized === '0') return 'Non'
|
||||||
|
}
|
||||||
|
return raw || 'Non défini'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a field has a displayable value (readOnly fields always display) */
|
||||||
|
export function hasDisplayableValue(field: CustomFieldInput): boolean {
|
||||||
|
if (field.readOnly) return true
|
||||||
|
if (field.type === 'boolean') return field.value !== undefined && field.value !== null && field.value !== ''
|
||||||
|
return typeof field.value === 'string' && field.value.trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable key for v-for rendering */
|
||||||
|
export function fieldKey(field: CustomFieldInput, index: number): string {
|
||||||
|
return field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Persistence helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Whether a field should be persisted (non-empty value) */
|
||||||
|
export function shouldPersist(field: CustomFieldInput): boolean {
|
||||||
|
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
|
||||||
|
if (typeof field.value === 'number') return !Number.isNaN(field.value)
|
||||||
|
return typeof field.value === 'string' && field.value.trim() !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format value for save (trim, boolean coercion) */
|
||||||
|
export function formatValueForSave(field: CustomFieldInput): string {
|
||||||
|
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
|
||||||
|
if (typeof field.value === 'number') return String(field.value)
|
||||||
|
return typeof field.value === 'string' ? field.value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if all required fields are filled */
|
||||||
|
export function requiredFieldsFilled(fields: CustomFieldInput[]): boolean {
|
||||||
|
return fields.every((field) => {
|
||||||
|
if (!field.required) return true
|
||||||
|
return shouldPersist(field)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure functions for custom field resolution, merging, and deduplication.
|
|
||||||
*
|
|
||||||
* Extracted from ComponentItem.vue and PieceItem.vue which had ~350 LOC
|
|
||||||
* of identical custom field logic duplicated between them.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Field key / identity helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function fieldKeyFromNameAndType(name: unknown, type: unknown): string | null {
|
|
||||||
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''
|
|
||||||
const normalizedType = typeof type === 'string' ? type.trim().toLowerCase() : ''
|
|
||||||
return normalizedName ? `${normalizedName}::${normalizedType}` : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveOrderIndex(field: any): number {
|
|
||||||
if (!field || typeof field !== 'object') return 0
|
|
||||||
if (typeof field.orderIndex === 'number') return field.orderIndex
|
|
||||||
if (field.customField && typeof field.customField.orderIndex === 'number') return field.customField.orderIndex
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Field accessors
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function resolveFieldKey(field: any, index: number): string {
|
|
||||||
return field?.id ?? field?.customFieldValueId ?? field?.customFieldId ?? field?.name ?? `field-${index}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveFieldId(field: any): string | null {
|
|
||||||
return field?.customFieldValueId ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveFieldName(field: any): string {
|
|
||||||
return field?.name ?? 'Champ'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveFieldType(field: any): string {
|
|
||||||
return field?.type ?? 'text'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveFieldOptions(field: any): string[] {
|
|
||||||
return field?.options ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveFieldRequired(field: any): boolean {
|
|
||||||
return !!field?.required
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveFieldReadOnly(field: any): boolean {
|
|
||||||
return !!field?.readOnly
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveCustomFieldId(field: any): string | null {
|
|
||||||
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildCustomFieldMetadata(field: any) {
|
|
||||||
return {
|
|
||||||
customFieldName: resolveFieldName(field),
|
|
||||||
customFieldType: resolveFieldType(field),
|
|
||||||
customFieldRequired: resolveFieldRequired(field),
|
|
||||||
customFieldOptions: resolveFieldOptions(field),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFieldDisplayValue(field: any): string {
|
|
||||||
const type = resolveFieldType(field)
|
|
||||||
const rawValue = field?.value ?? ''
|
|
||||||
if (type === 'boolean') {
|
|
||||||
const normalized = String(rawValue).toLowerCase()
|
|
||||||
if (normalized === 'true') return 'Oui'
|
|
||||||
if (normalized === 'false') return 'Non'
|
|
||||||
}
|
|
||||||
return rawValue || 'Non défini'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Custom field ID resolution against candidate pool
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function ensureCustomFieldId(field: any, candidateFields: any[]): string | null {
|
|
||||||
const existingId = resolveCustomFieldId(field)
|
|
||||||
if (existingId) return existingId
|
|
||||||
|
|
||||||
const name = resolveFieldName(field)
|
|
||||||
if (!name || name === 'Champ') return null
|
|
||||||
|
|
||||||
const matches = candidateFields.filter((candidate) => {
|
|
||||||
if (!candidate || typeof candidate !== 'object') return false
|
|
||||||
const candidateId = candidate.id || candidate.customFieldId
|
|
||||||
if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) return true
|
|
||||||
return typeof candidate.name === 'string' && candidate.name === name
|
|
||||||
})
|
|
||||||
|
|
||||||
if (matches.length) {
|
|
||||||
const withId = matches.find((c) => c?.id || c?.customFieldId) || matches[0]
|
|
||||||
const id = withId?.id || withId?.customFieldId || null
|
|
||||||
if (id) field.customFieldId = id
|
|
||||||
if (!field.customField && typeof withId === 'object') field.customField = withId
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Structure extraction
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function extractStructureCustomFields(structure: any): any[] {
|
|
||||||
if (!structure || typeof structure !== 'object') return []
|
|
||||||
const customFields = structure.customFields
|
|
||||||
return Array.isArray(customFields) ? customFields : []
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Deduplication & merge
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function deduplicateFieldDefinitions(definitions: any[]): any[] {
|
|
||||||
const result: any[] = []
|
|
||||||
const seenIds = new Set<string>()
|
|
||||||
const seenNames = new Set<string>()
|
|
||||||
|
|
||||||
const orderedDefinitions = (Array.isArray(definitions) ? definitions.slice() : [])
|
|
||||||
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
|
||||||
|
|
||||||
orderedDefinitions.forEach((field) => {
|
|
||||||
if (!field || typeof field !== 'object') return
|
|
||||||
const id = field.id ?? field.customFieldId ?? field.customField?.id ?? null
|
|
||||||
const nameKey = fieldKeyFromNameAndType(field.name, field.type)
|
|
||||||
// Deduplicate by name+type (primary) AND by id — a field with the same
|
|
||||||
// name+type is the same field even when stored with different IDs.
|
|
||||||
if (nameKey && seenNames.has(nameKey)) return
|
|
||||||
if (id && seenIds.has(id)) return
|
|
||||||
if (id) seenIds.add(id)
|
|
||||||
if (nameKey) seenNames.add(nameKey)
|
|
||||||
field.orderIndex = resolveOrderIndex(field)
|
|
||||||
result.push(field)
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeFieldDefinitionsWithValues(definitions: any[], values: any[]): any[] {
|
|
||||||
const definitionList = Array.isArray(definitions) ? definitions : []
|
|
||||||
const valueList = Array.isArray(values) ? values : []
|
|
||||||
|
|
||||||
const valueMap = new Map<string, any>()
|
|
||||||
valueList.forEach((entry) => {
|
|
||||||
if (!entry || typeof entry !== 'object') return
|
|
||||||
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
|
||||||
if (fieldId) valueMap.set(fieldId, entry)
|
|
||||||
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
|
||||||
if (nameKey) valueMap.set(nameKey, entry)
|
|
||||||
})
|
|
||||||
|
|
||||||
const merged = definitionList.map((field) => {
|
|
||||||
if (!field || typeof field !== 'object') return field
|
|
||||||
|
|
||||||
const fieldId = resolveCustomFieldId(field)
|
|
||||||
const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
|
|
||||||
const matchedValue = (fieldId ? valueMap.get(fieldId) : undefined) ?? (nameKey ? valueMap.get(nameKey) : undefined)
|
|
||||||
|
|
||||||
if (!matchedValue) {
|
|
||||||
return { ...field, value: field?.value ?? '', orderIndex: resolveOrderIndex(field) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedOrder = Math.min(resolveOrderIndex(field), resolveOrderIndex(matchedValue.customField))
|
|
||||||
return {
|
|
||||||
...field,
|
|
||||||
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
|
|
||||||
customFieldId: matchedValue.customField?.id ?? matchedValue.customFieldId ?? fieldId ?? null,
|
|
||||||
customField: matchedValue.customField ?? field.customField ?? null,
|
|
||||||
value: matchedValue.value ?? field.value ?? '',
|
|
||||||
orderIndex: resolvedOrder,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
valueList.forEach((entry) => {
|
|
||||||
if (!entry || typeof entry !== 'object') return
|
|
||||||
|
|
||||||
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
|
|
||||||
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
|
|
||||||
|
|
||||||
const exists = merged.some((field) => {
|
|
||||||
if (!field || typeof field !== 'object') return false
|
|
||||||
if (field.customFieldValueId && field.customFieldValueId === entry.id) return true
|
|
||||||
const existingId = resolveCustomFieldId(field)
|
|
||||||
if (fieldId && existingId && existingId === fieldId) return true
|
|
||||||
if (!fieldId && nameKey) {
|
|
||||||
return fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
merged.push({
|
|
||||||
customFieldValueId: entry.id ?? null,
|
|
||||||
customFieldId: fieldId,
|
|
||||||
name: entry.customField?.name ?? '',
|
|
||||||
type: entry.customField?.type ?? 'text',
|
|
||||||
required: entry.customField?.required ?? false,
|
|
||||||
options: entry.customField?.options ?? [],
|
|
||||||
value: entry.value ?? '',
|
|
||||||
customField: entry.customField ?? null,
|
|
||||||
orderIndex: resolveOrderIndex(entry.customField),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dedupeMergedFields(fields: any[]): any[] {
|
|
||||||
if (!Array.isArray(fields) || fields.length <= 1) {
|
|
||||||
return Array.isArray(fields)
|
|
||||||
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
|
|
||||||
const seenById = new Map<string, any>()
|
|
||||||
const seenByName = new Map<string, any>()
|
|
||||||
const result: any[] = []
|
|
||||||
|
|
||||||
const orderedFields = fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
|
||||||
|
|
||||||
orderedFields.forEach((field) => {
|
|
||||||
if (!field || typeof field !== 'object') return
|
|
||||||
|
|
||||||
const rawName = resolveFieldName(field)
|
|
||||||
const normalizedName = typeof rawName === 'string' ? rawName.trim() : ''
|
|
||||||
if (!normalizedName) return
|
|
||||||
|
|
||||||
field.name = normalizedName
|
|
||||||
field.type = field.type || resolveFieldType(field)
|
|
||||||
|
|
||||||
const fieldId = resolveCustomFieldId(field)
|
|
||||||
const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
|
|
||||||
|
|
||||||
// Check duplicates by name+type first (same field can have different IDs)
|
|
||||||
const existing = (nameKey ? seenByName.get(nameKey) : undefined) ?? (fieldId ? seenById.get(fieldId) : undefined)
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
field.orderIndex = resolveOrderIndex(field)
|
|
||||||
if (fieldId) seenById.set(fieldId, field)
|
|
||||||
if (nameKey) seenByName.set(nameKey, field)
|
|
||||||
result.push(field)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingHasValue = existing.value !== undefined && existing.value !== null && String(existing.value).trim().length > 0
|
|
||||||
const incomingHasValue = field.value !== undefined && field.value !== null && String(field.value).trim().length > 0
|
|
||||||
|
|
||||||
if (!existingHasValue && incomingHasValue) {
|
|
||||||
Object.assign(existing, field)
|
|
||||||
existing.orderIndex = Math.min(resolveOrderIndex(existing), resolveOrderIndex(field))
|
|
||||||
if (fieldId) seenById.set(fieldId, existing)
|
|
||||||
if (nameKey) seenByName.set(nameKey, existing)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Definition sources builder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function buildDefinitionSources(entity: any, entityType: 'composant' | 'piece'): any[] {
|
|
||||||
const definitions: any[] = []
|
|
||||||
const pushFields = (collection: any) => {
|
|
||||||
if (Array.isArray(collection)) definitions.push(...collection)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entityType === 'composant') {
|
|
||||||
const type = entity.typeComposant || {}
|
|
||||||
|
|
||||||
pushFields(entity.customFields)
|
|
||||||
pushFields(entity.definition?.customFields)
|
|
||||||
pushFields(type.customFields)
|
|
||||||
|
|
||||||
;[
|
|
||||||
entity.definition?.structure,
|
|
||||||
type.structure,
|
|
||||||
].forEach((structure) => {
|
|
||||||
const fields = extractStructureCustomFields(structure)
|
|
||||||
if (fields.length) definitions.push(...fields)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const type = entity.typePiece || {}
|
|
||||||
|
|
||||||
pushFields(entity.customFields)
|
|
||||||
pushFields(entity.definition?.customFields)
|
|
||||||
pushFields(type.customFields)
|
|
||||||
|
|
||||||
;[
|
|
||||||
entity.definition?.structure,
|
|
||||||
type.structure,
|
|
||||||
].forEach((structure) => {
|
|
||||||
const fields = extractStructureCustomFields(structure)
|
|
||||||
if (fields.length) definitions.push(...fields)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return deduplicateFieldDefinitions(definitions)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Candidate fields builder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function buildCandidateCustomFields(entity: any, definitionSources: any[]): any[] {
|
|
||||||
const map = new Map<string, any>()
|
|
||||||
const register = (collection: any[]) => {
|
|
||||||
if (!Array.isArray(collection)) return
|
|
||||||
collection.forEach((item) => {
|
|
||||||
if (!item || typeof item !== 'object') return
|
|
||||||
const id = item.id || item.customFieldId
|
|
||||||
const name = typeof item.name === 'string' ? item.name : null
|
|
||||||
const key = id || (name ? `${name}::${item.type ?? ''}` : null)
|
|
||||||
if (!key || map.has(key)) return
|
|
||||||
map.set(key, item)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
register((entity.customFieldValues || []).map((value: any) => value?.customField))
|
|
||||||
register(definitionSources)
|
|
||||||
|
|
||||||
return Array.from(map.values())
|
|
||||||
}
|
|
||||||
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@@ -98,6 +98,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
|
||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -2092,6 +2093,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -4112,6 +4114,7 @@
|
|||||||
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
|
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -4181,6 +4184,7 @@
|
|||||||
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
|
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.44.1",
|
"@typescript-eslint/scope-manager": "8.44.1",
|
||||||
"@typescript-eslint/types": "8.44.1",
|
"@typescript-eslint/types": "8.44.1",
|
||||||
@@ -4977,6 +4981,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
||||||
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.4",
|
"@babel/parser": "^7.28.4",
|
||||||
"@vue/compiler-core": "3.5.22",
|
"@vue/compiler-core": "3.5.22",
|
||||||
@@ -5207,6 +5212,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5637,6 +5643,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.3",
|
"baseline-browser-mapping": "^2.8.3",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
@@ -7069,6 +7076,7 @@
|
|||||||
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
|
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -10490,6 +10498,7 @@
|
|||||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deep-is": "^0.1.3",
|
"deep-is": "^0.1.3",
|
||||||
"fast-levenshtein": "^2.0.6",
|
"fast-levenshtein": "^2.0.6",
|
||||||
@@ -10536,6 +10545,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.87.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.87.0.tgz",
|
||||||
"integrity": "sha512-uc47XrtHwkBoES4HFgwgfH9sqwAtJXgAIBq4fFBMZ4hWmgVZoExyn+L4g4VuaecVKXkz1bvlaHcfwHAJPQb5Gw==",
|
"integrity": "sha512-uc47XrtHwkBoES4HFgwgfH9sqwAtJXgAIBq4fFBMZ4hWmgVZoExyn+L4g4VuaecVKXkz1bvlaHcfwHAJPQb5Gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "^0.87.0"
|
"@oxc-project/types": "^0.87.0"
|
||||||
},
|
},
|
||||||
@@ -10937,6 +10947,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -11376,6 +11387,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
|
||||||
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
|
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssesc": "^3.0.0",
|
"cssesc": "^3.0.0",
|
||||||
"util-deprecate": "^1.0.2"
|
"util-deprecate": "^1.0.2"
|
||||||
@@ -12118,6 +12130,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
||||||
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -13180,6 +13193,7 @@
|
|||||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -13537,6 +13551,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napi-postinstall": "^0.3.0"
|
"napi-postinstall": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -13783,6 +13798,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
||||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -14186,6 +14202,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.22",
|
"@vue/compiler-dom": "3.5.22",
|
||||||
"@vue/compiler-sfc": "3.5.22",
|
"@vue/compiler-sfc": "3.5.22",
|
||||||
@@ -14230,6 +14247,7 @@
|
|||||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"eslint-scope": "^8.2.0",
|
"eslint-scope": "^8.2.0",
|
||||||
@@ -14253,6 +14271,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||||
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
|
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^6.6.4"
|
"@vue/devtools-api": "^6.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
737
frontend/tests/composables/useComponentCreate.test.ts
Normal file
737
frontend/tests/composables/useComponentCreate.test.ts
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { mockLinkSKF, mockLinkFAG } from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — API layer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
const mockPostFormData = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: mockPostFormData,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — Toast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockShowSuccess = vi.fn()
|
||||||
|
const mockShowError = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: mockShowSuccess,
|
||||||
|
showError: mockShowError,
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useComposants (createComposant)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockCreateComposant = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComposants', () => ({
|
||||||
|
useComposants: () => ({
|
||||||
|
createComposant: mockCreateComposant,
|
||||||
|
composants: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePieces, useProducts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieces', () => ({
|
||||||
|
usePieces: () => ({
|
||||||
|
pieces: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useProducts', () => ({
|
||||||
|
useProducts: () => ({
|
||||||
|
products: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useComponentTypes, usePieceTypes, useProductTypes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockLoadComponentTypes = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const mockComponentTypes = { value: [] as any[] }
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComponentTypes', () => ({
|
||||||
|
useComponentTypes: () => ({
|
||||||
|
componentTypes: mockComponentTypes,
|
||||||
|
loadComponentTypes: mockLoadComponentTypes,
|
||||||
|
loadingComponentTypes: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieceTypes', () => ({
|
||||||
|
usePieceTypes: () => ({
|
||||||
|
pieceTypes: { value: [] },
|
||||||
|
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useProductTypes', () => ({
|
||||||
|
useProductTypes: () => ({
|
||||||
|
productTypes: { value: [] },
|
||||||
|
loadProductTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useDocuments (uploadDocuments)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUploadDocuments = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useDocuments', () => ({
|
||||||
|
useDocuments: () => ({
|
||||||
|
uploadDocuments: mockUploadDocuments,
|
||||||
|
documents: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurLinks (syncLinks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurLinks', () => ({
|
||||||
|
useConstructeurLinks: () => ({
|
||||||
|
fetchLinks: vi.fn().mockResolvedValue([]),
|
||||||
|
syncLinks: mockSyncLinks,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useCustomFieldInputs (saveAll)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockSaveAll = vi.fn().mockResolvedValue([])
|
||||||
|
const mockRefreshCF = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFieldInputs', () => ({
|
||||||
|
useCustomFieldInputs: () => ({
|
||||||
|
fields: { value: [] },
|
||||||
|
requiredFilled: { value: true },
|
||||||
|
saveAll: mockSaveAll,
|
||||||
|
refresh: mockRefreshCF,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePermissions (auto-imported in Nuxt)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// usePermissions is Nuxt auto-imported (no explicit import in source),
|
||||||
|
// so we stub it as a global function.
|
||||||
|
vi.stubGlobal('usePermissions', () => ({
|
||||||
|
canEdit: { value: true },
|
||||||
|
canManage: { value: true },
|
||||||
|
isAdmin: { value: false },
|
||||||
|
isGranted: () => true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurs (used by useComposants internally)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — shared utils that touch structure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockHasAssignments = vi.fn().mockReturnValue(false)
|
||||||
|
const mockSerializeStructureAssignments = vi.fn().mockReturnValue(null)
|
||||||
|
const mockIsAssignmentNodeComplete = vi.fn().mockReturnValue(true)
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/structureAssignmentHelpers', () => ({
|
||||||
|
hasAssignments: (...args: any[]) => mockHasAssignments(...args),
|
||||||
|
initializeStructureAssignments: () => null,
|
||||||
|
isAssignmentNodeComplete: (...args: any[]) => mockIsAssignmentNodeComplete(...args),
|
||||||
|
serializeStructureAssignments: (...args: any[]) => mockSerializeStructureAssignments(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/structureDisplayUtils', () => ({
|
||||||
|
getStructurePieces: () => [],
|
||||||
|
resolvePieceLabel: (p: any) => p?.name ?? '',
|
||||||
|
resolveProductLabel: (p: any) => p?.name ?? '',
|
||||||
|
resolveSubcomponentLabel: (p: any) => p?.name ?? '',
|
||||||
|
fetchModelTypeNames: vi.fn().mockResolvedValue({}),
|
||||||
|
buildTypeLabelMap: () => ({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/modelUtils', () => ({
|
||||||
|
formatStructurePreview: () => '',
|
||||||
|
normalizeStructureForEditor: (s: any) => s,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/errorMessages', () => ({
|
||||||
|
humanizeError: (msg: string) => msg,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/constructeurUtils', () => ({
|
||||||
|
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
|
||||||
|
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import under test (AFTER all vi.mock calls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useComponentCreate } from '~/composables/useComponentCreate'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A minimal ModelType matching the `COMPONENT` category filter. */
|
||||||
|
const mockModelType = {
|
||||||
|
id: 'tc-moteur',
|
||||||
|
name: 'Moteur électrique',
|
||||||
|
category: 'COMPONENT',
|
||||||
|
structure: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Provide at least one COMPONENT type so selectedType resolves
|
||||||
|
mockComponentTypes.value = [mockModelType]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// submitCreation — payload completeness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitCreation — payload completeness', () => {
|
||||||
|
it('includes all form fields in createComposant payload', async () => {
|
||||||
|
const createdComp = { id: 'comp-new-001', name: 'Moteur principal' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
|
||||||
|
// Select a type
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
// Wait a tick so watchers fire
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
// Fill form fields
|
||||||
|
composable.creationForm.name = 'Moteur principal'
|
||||||
|
composable.creationForm.description = 'Un moteur triphasé'
|
||||||
|
composable.creationForm.reference = 'MOT-001'
|
||||||
|
composable.creationForm.prix = '1500'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
name: 'Moteur principal',
|
||||||
|
description: 'Un moteur triphasé',
|
||||||
|
reference: 'MOT-001',
|
||||||
|
prix: '1500',
|
||||||
|
typeComposantId: 'tc-moteur',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves custom fields after component creation (saveAll is called)', async () => {
|
||||||
|
const createdComp = { id: 'comp-cf-001', name: 'Composant CF' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant CF'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs constructeur links after creation with correct entity type and ID', async () => {
|
||||||
|
const createdComp = { id: 'comp-link-001', name: 'Composant Links' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Links'
|
||||||
|
// Add constructeur links
|
||||||
|
composable.constructeurLinks.value = [mockLinkSKF, mockLinkFAG]
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledWith(
|
||||||
|
'composant',
|
||||||
|
'comp-link-001',
|
||||||
|
[],
|
||||||
|
[mockLinkSKF, mockLinkFAG],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads documents with correct composantId context', async () => {
|
||||||
|
const createdComp = { id: 'comp-doc-001', name: 'Composant Docs' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Docs'
|
||||||
|
|
||||||
|
// Simulate selected documents
|
||||||
|
const fakeFile = new File(['content'], 'schema.pdf', { type: 'application/pdf' })
|
||||||
|
composable.selectedDocuments.value = [fakeFile]
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockUploadDocuments).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockUploadDocuments).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
files: [fakeFile],
|
||||||
|
context: { composantId: 'comp-doc-001' },
|
||||||
|
},
|
||||||
|
{ updateStore: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not crash with zero constructeurs', async () => {
|
||||||
|
const createdComp = { id: 'comp-no-cstr', name: 'Composant Simple' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Simple'
|
||||||
|
// Ensure no constructeur links
|
||||||
|
composable.constructeurLinks.value = []
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
||||||
|
expect(mockShowSuccess).toHaveBeenCalledWith('Composant créé avec succès')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Structure serialization in payload
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitCreation — structure serialization in payload', () => {
|
||||||
|
it('includes structure key with serialized data when assignments exist', async () => {
|
||||||
|
const createdComp = { id: 'comp-struct-001', name: 'Composant Structure' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const fakeSerializedStructure = {
|
||||||
|
path: 'root',
|
||||||
|
definition: { typeComposantId: 'tc-moteur' },
|
||||||
|
pieces: [{ path: 'root:piece-0', definition: { typePieceId: 'tp-001' }, selectedPieceId: 'piece-abc' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(true)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(fakeSerializedStructure)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Structure'
|
||||||
|
// Set a non-null structureAssignments so the composable considers it present
|
||||||
|
composable.structureAssignments.value = {
|
||||||
|
path: 'root',
|
||||||
|
definition: {} as any,
|
||||||
|
selectedComponentId: '',
|
||||||
|
pieces: [{ path: 'root:piece-0', definition: {} as any, selectedPieceId: 'piece-abc' }],
|
||||||
|
products: [],
|
||||||
|
subcomponents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload.structure).toEqual(fakeSerializedStructure)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include structure key when no assignments exist', async () => {
|
||||||
|
const createdComp = { id: 'comp-nostruct-001', name: 'Composant No Structure' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
// Reset to default: no assignments
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant No Structure'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload.structure).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include structure key when serializeStructureAssignments returns null', async () => {
|
||||||
|
const createdComp = { id: 'comp-sernull-001', name: 'Composant Serialize Null' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(true)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Serialize Null'
|
||||||
|
composable.structureAssignments.value = {
|
||||||
|
path: 'root',
|
||||||
|
definition: {} as any,
|
||||||
|
selectedComponentId: '',
|
||||||
|
pieces: [],
|
||||||
|
products: [],
|
||||||
|
subcomponents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload.structure).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prix / reference null handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitCreation — prix and reference null handling', () => {
|
||||||
|
it('does not send prix when prix is an empty string', async () => {
|
||||||
|
const createdComp = { id: 'comp-noprix-001', name: 'Composant No Prix' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
// Reset structure mocks to default
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant No Prix'
|
||||||
|
composable.creationForm.prix = ''
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('prix')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send prix when prix is non-numeric (avoids NaN)', async () => {
|
||||||
|
const createdComp = { id: 'comp-nanprix-001', name: 'Composant NaN Prix' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant NaN Prix'
|
||||||
|
composable.creationForm.prix = 'not-a-number'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('prix')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends prix as stringified number when valid numeric string', async () => {
|
||||||
|
const createdComp = { id: 'comp-validprix-001', name: 'Composant Valid Prix' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Valid Prix'
|
||||||
|
composable.creationForm.prix = ' 42.5 '
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload.prix).toBe('42.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send reference when reference is an empty string', async () => {
|
||||||
|
const createdComp = { id: 'comp-noref-001', name: 'Composant No Ref' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant No Ref'
|
||||||
|
composable.creationForm.reference = ''
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('reference')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send reference when reference is whitespace only', async () => {
|
||||||
|
const createdComp = { id: 'comp-wsref-001', name: 'Composant WS Ref' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant WS Ref'
|
||||||
|
composable.creationForm.reference = ' '
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('reference')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitCreation — error paths', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not save custom fields when createComposant returns success: false', async () => {
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: false, error: 'Duplicate name' })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Fail'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith('Duplicate name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows toast error when createComposant returns success: false with error message', async () => {
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: false, error: 'Server validation failed' })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Error'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith('Server validation failed')
|
||||||
|
expect(mockShowSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows warning for failed custom fields but still navigates (composant exists)', async () => {
|
||||||
|
const createdComp = { id: 'comp-cf-warn-001', name: 'Composant CF Warn' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
mockSaveAll.mockResolvedValue(['Tension nominale', 'Certifié CE'])
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant CF Warn'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
// Custom field error toast is shown
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith(
|
||||||
|
'Erreur sur les champs : Tension nominale, Certifié CE',
|
||||||
|
)
|
||||||
|
// But creation success toast is also shown (composant was created)
|
||||||
|
expect(mockShowSuccess).toHaveBeenCalledWith('Composant créé avec succès')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('catches thrown exceptions and shows humanized error', async () => {
|
||||||
|
mockCreateComposant.mockRejectedValue(new Error('Network timeout'))
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Throw'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith('Network timeout')
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets submitting flag after failure', async () => {
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: false, error: 'fail' })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Reset Flag'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(composable.submitting.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ProductId from structure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitCreation — productId from structure', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes productId in payload when root product selection exists', async () => {
|
||||||
|
const createdComp = { id: 'comp-prodid-001', name: 'Composant ProductId' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant ProductId'
|
||||||
|
// Set structure assignments with a root product selection
|
||||||
|
composable.structureAssignments.value = {
|
||||||
|
path: 'root',
|
||||||
|
definition: {} as any,
|
||||||
|
selectedComponentId: '',
|
||||||
|
pieces: [],
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
path: 'root:product-0',
|
||||||
|
definition: { typeProductId: 'tprod-001' } as any,
|
||||||
|
selectedProductId: 'prod-selected-123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subcomponents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload.productId).toBe('prod-selected-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include productId when no root product is selected', async () => {
|
||||||
|
const createdComp = { id: 'comp-noprodid-001', name: 'Composant No ProductId' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant No ProductId'
|
||||||
|
composable.structureAssignments.value = {
|
||||||
|
path: 'root',
|
||||||
|
definition: {} as any,
|
||||||
|
selectedComponentId: '',
|
||||||
|
pieces: [],
|
||||||
|
products: [],
|
||||||
|
subcomponents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('productId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include productId when product selection is empty string', async () => {
|
||||||
|
const createdComp = { id: 'comp-emptyprod-001', name: 'Composant Empty Product' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Empty Product'
|
||||||
|
composable.structureAssignments.value = {
|
||||||
|
path: 'root',
|
||||||
|
definition: {} as any,
|
||||||
|
selectedComponentId: '',
|
||||||
|
pieces: [],
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
path: 'root:product-0',
|
||||||
|
definition: { typeProductId: 'tprod-001' } as any,
|
||||||
|
selectedProductId: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subcomponents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('productId')
|
||||||
|
})
|
||||||
|
})
|
||||||
890
frontend/tests/composables/useComponentEdit.test.ts
Normal file
890
frontend/tests/composables/useComponentEdit.test.ts
Normal file
@@ -0,0 +1,890 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
mockComponentFromApi,
|
||||||
|
mockLinkSKF,
|
||||||
|
mockLinkFAG,
|
||||||
|
mockConstructeurSKF,
|
||||||
|
wrapCollection,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — API layer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
const mockPostFormData = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: mockPostFormData,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — Toast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockShowSuccess = vi.fn()
|
||||||
|
const mockShowError = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: mockShowSuccess,
|
||||||
|
showError: mockShowError,
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useComposants (updateComposant)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUpdateComposant = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComposants', () => ({
|
||||||
|
useComposants: () => ({
|
||||||
|
updateComposant: mockUpdateComposant,
|
||||||
|
composants: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePieces, useProducts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieces', () => ({
|
||||||
|
usePieces: () => ({
|
||||||
|
pieces: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useProducts', () => ({
|
||||||
|
useProducts: () => ({
|
||||||
|
products: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useComponentTypes, usePieceTypes, useProductTypes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockLoadComponentTypes = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const mockComponentTypes = { value: [] as any[] }
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComponentTypes', () => ({
|
||||||
|
useComponentTypes: () => ({
|
||||||
|
componentTypes: mockComponentTypes,
|
||||||
|
loadComponentTypes: mockLoadComponentTypes,
|
||||||
|
loadingComponentTypes: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieceTypes', () => ({
|
||||||
|
usePieceTypes: () => ({
|
||||||
|
pieceTypes: { value: [] },
|
||||||
|
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useProductTypes', () => ({
|
||||||
|
useProductTypes: () => ({
|
||||||
|
productTypes: { value: [] },
|
||||||
|
loadProductTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useDocuments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockLoadDocumentsByComponent = vi.fn().mockResolvedValue({ success: true, data: [] })
|
||||||
|
const mockUploadDocuments = vi.fn().mockResolvedValue({ success: true, data: [] })
|
||||||
|
const mockDeleteDocument = vi.fn().mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
vi.mock('~/composables/useDocuments', () => ({
|
||||||
|
useDocuments: () => ({
|
||||||
|
loadDocumentsByComponent: mockLoadDocumentsByComponent,
|
||||||
|
uploadDocuments: mockUploadDocuments,
|
||||||
|
deleteDocument: mockDeleteDocument,
|
||||||
|
documents: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurLinks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockFetchLinks = vi.fn().mockResolvedValue([])
|
||||||
|
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurLinks', () => ({
|
||||||
|
useConstructeurLinks: () => ({
|
||||||
|
fetchLinks: mockFetchLinks,
|
||||||
|
syncLinks: mockSyncLinks,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useCustomFieldInputs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockSaveAll = vi.fn().mockResolvedValue([])
|
||||||
|
const mockRefreshCF = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFieldInputs', () => ({
|
||||||
|
useCustomFieldInputs: () => ({
|
||||||
|
fields: { value: [] },
|
||||||
|
requiredFilled: { value: true },
|
||||||
|
saveAll: mockSaveAll,
|
||||||
|
refresh: mockRefreshCF,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePermissions (auto-imported in Nuxt)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.stubGlobal('usePermissions', () => ({
|
||||||
|
canEdit: { value: true },
|
||||||
|
canManage: { value: true },
|
||||||
|
isAdmin: { value: false },
|
||||||
|
isGranted: () => true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useEntityHistory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useEntityHistory', () => ({
|
||||||
|
useEntityHistory: () => ({
|
||||||
|
history: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
error: { value: null },
|
||||||
|
loadHistory: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — shared utils
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/structureDisplayUtils', () => ({
|
||||||
|
getStructurePieces: (s: any) => Array.isArray(s?.pieces) ? s.pieces : [],
|
||||||
|
getStructureProducts: (s: any) => Array.isArray(s?.products) ? s.products : [],
|
||||||
|
resolvePieceLabel: (p: any) => p?.name ?? '',
|
||||||
|
resolveProductLabel: (p: any) => p?.name ?? '',
|
||||||
|
resolveSubcomponentLabel: (p: any) => p?.name ?? '',
|
||||||
|
fetchModelTypeNames: vi.fn().mockResolvedValue({}),
|
||||||
|
buildTypeLabelMap: () => ({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/modelUtils', () => ({
|
||||||
|
formatStructurePreview: () => '',
|
||||||
|
normalizeStructureForEditor: (s: any) => s,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/constructeurUtils', () => ({
|
||||||
|
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
|
||||||
|
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/structureSelectionUtils', () => ({
|
||||||
|
collectStructureSelections: () => ({ pieces: [], products: [], components: [] }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/utils/documentPreview', () => ({
|
||||||
|
canPreviewDocument: () => false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import under test (AFTER all vi.mock calls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useComponentEdit } from '~/composables/useComponentEdit'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test data — component with structure containing slots
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const COMPONENT_ID = 'cl-comp-1'
|
||||||
|
|
||||||
|
function buildComponentWithStructure() {
|
||||||
|
return {
|
||||||
|
...mockComponentFromApi,
|
||||||
|
id: COMPONENT_ID,
|
||||||
|
'@id': `/api/composants/${COMPONENT_ID}`,
|
||||||
|
description: 'Un moteur triphas\u00e9 haute performance',
|
||||||
|
prix: '1500.00',
|
||||||
|
typeComposantId: 'tc-moteur',
|
||||||
|
structure: {
|
||||||
|
pieces: [
|
||||||
|
{
|
||||||
|
slotId: 'ps-001',
|
||||||
|
typePieceId: 'tp-bearing-001',
|
||||||
|
selectedPieceId: 'piece-001',
|
||||||
|
selectedPieceName: 'Roulement 6205',
|
||||||
|
quantity: 2,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slotId: 'ps-002',
|
||||||
|
typePieceId: 'tp-seal-002',
|
||||||
|
selectedPieceId: 'piece-002',
|
||||||
|
selectedPieceName: 'Joint torique',
|
||||||
|
quantity: 1,
|
||||||
|
position: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
slotId: 'prs-001',
|
||||||
|
typeProductId: 'tprod-grease-001',
|
||||||
|
selectedProductId: 'prod-001',
|
||||||
|
selectedProductName: 'Graisse LGMT2',
|
||||||
|
familyCode: 'LUB',
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subcomponents: [
|
||||||
|
{
|
||||||
|
slotId: 'scs-001',
|
||||||
|
typeComposantId: 'tc-sub-001',
|
||||||
|
selectedComponentId: 'comp-sub-001',
|
||||||
|
selectedComponentName: 'Palier avant',
|
||||||
|
alias: 'Palier avant',
|
||||||
|
familyCode: 'PAL',
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Wait for next tick + micro-tasks so watchers fire. */
|
||||||
|
const tick = () => new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the composable AND hydrate it by resolving the mocked get.
|
||||||
|
* Returns the composable instance after fetch + watcher hydration.
|
||||||
|
*/
|
||||||
|
async function createAndHydrate(overrides?: Partial<ReturnType<typeof buildComponentWithStructure>>) {
|
||||||
|
const comp = { ...buildComponentWithStructure(), ...overrides }
|
||||||
|
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url.includes(`/composants/${COMPONENT_ID}`)) {
|
||||||
|
return Promise.resolve({ success: true, data: structuredClone(comp) })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true, data: wrapCollection([]) })
|
||||||
|
})
|
||||||
|
|
||||||
|
mockFetchLinks.mockResolvedValue([
|
||||||
|
{ ...mockLinkSKF },
|
||||||
|
])
|
||||||
|
|
||||||
|
const composable = useComponentEdit(COMPONENT_ID)
|
||||||
|
|
||||||
|
// fetchComponent is called, then the watcher hydrates editionForm
|
||||||
|
await composable.fetchComponent()
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
return composable
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// beforeEach
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockComponentTypes.value = [
|
||||||
|
{ id: 'tc-moteur', name: 'Moteur \u00e9lectrique', category: 'COMPONENT', structure: null },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// fetchComponent — hydration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('fetchComponent — hydration', () => {
|
||||||
|
it('loads simple fields into editionForm (name, reference, description, prix)', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(composable.editionForm.name).toBe('Moteur principal')
|
||||||
|
expect(composable.editionForm.reference).toBe('COMP-MOT-001')
|
||||||
|
expect(composable.editionForm.description).toBe('Un moteur triphas\u00e9 haute performance')
|
||||||
|
expect(composable.editionForm.prix).toBe('1500.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads component object with structure containing slots', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(composable.component.value).not.toBeNull()
|
||||||
|
expect(composable.component.value.structure).toBeDefined()
|
||||||
|
expect(composable.component.value.structure.pieces).toHaveLength(2)
|
||||||
|
expect(composable.component.value.structure.products).toHaveLength(1)
|
||||||
|
expect(composable.component.value.structure.subcomponents).toHaveLength(1)
|
||||||
|
expect(composable.component.value.customFieldValues).toBeDefined()
|
||||||
|
expect(Array.isArray(composable.component.value.customFieldValues)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads constructeur links via fetchLinks', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(mockFetchLinks).toHaveBeenCalledWith('composant', COMPONENT_ID)
|
||||||
|
expect(composable.constructeurLinks.value).toHaveLength(1)
|
||||||
|
expect(composable.constructeurLinks.value[0].constructeurId).toBe(mockConstructeurSKF.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slot operations — no data loss
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('slot operations — no data loss', () => {
|
||||||
|
it('setting piece slot selection preserves product and subcomponent slots', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Record initial product and subcomponent slot entries
|
||||||
|
const initialProductSlots = composable.productSlotEntries.value
|
||||||
|
const initialSubSlots = composable.subcomponentSlotEntries.value
|
||||||
|
|
||||||
|
expect(initialProductSlots).toHaveLength(1)
|
||||||
|
expect(initialSubSlots).toHaveLength(1)
|
||||||
|
|
||||||
|
// Change a piece slot selection
|
||||||
|
composable.setPieceSlotSelection('ps-001', 'piece-999')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Piece slot changed
|
||||||
|
const pieceSlots = composable.pieceSlotEntries.value
|
||||||
|
expect(pieceSlots.find(s => s.slotId === 'ps-001')?.selectedPieceId).toBe('piece-999')
|
||||||
|
|
||||||
|
// Product and subcomponent slots untouched
|
||||||
|
expect(composable.productSlotEntries.value).toHaveLength(1)
|
||||||
|
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-001')
|
||||||
|
expect(composable.subcomponentSlotEntries.value).toHaveLength(1)
|
||||||
|
expect(composable.subcomponentSlotEntries.value[0].selectedComponentId).toBe('comp-sub-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setting product slot selection preserves piece slots', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Change a product slot
|
||||||
|
composable.setProductSlotSelection('prs-001', 'prod-new-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Product changed
|
||||||
|
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-new-001')
|
||||||
|
|
||||||
|
// Piece slots untouched
|
||||||
|
expect(composable.pieceSlotEntries.value).toHaveLength(2)
|
||||||
|
expect(composable.pieceSlotEntries.value[0].selectedPieceId).toBe('piece-001')
|
||||||
|
expect(composable.pieceSlotEntries.value[1].selectedPieceId).toBe('piece-002')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setting subcomponent slot selection preserves piece and product slots', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Change a subcomponent slot
|
||||||
|
composable.setSubcomponentSlotSelection('scs-001', 'comp-new-sub')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Subcomponent changed
|
||||||
|
expect(composable.subcomponentSlotEntries.value[0].selectedComponentId).toBe('comp-new-sub')
|
||||||
|
|
||||||
|
// Piece and product slots untouched
|
||||||
|
expect(composable.pieceSlotEntries.value[0].selectedPieceId).toBe('piece-001')
|
||||||
|
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setting slot quantity preserves selectedPieceId', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Set a piece selection first
|
||||||
|
composable.setPieceSlotSelection('ps-001', 'piece-special')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Now change quantity on the same slot
|
||||||
|
composable.setSlotQuantity('ps-001', 5)
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
const slot = composable.pieceSlotEntries.value.find(s => s.slotId === 'ps-001')
|
||||||
|
expect(slot?.selectedPieceId).toBe('piece-special')
|
||||||
|
expect(slot?.quantity).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// submitEdition — no data loss
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitEdition — no data loss', () => {
|
||||||
|
it('sends all form fields in PATCH payload via updateComposant', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Modify form fields
|
||||||
|
composable.editionForm.name = 'Moteur modifi\u00e9'
|
||||||
|
composable.editionForm.description = 'Nouvelle description'
|
||||||
|
composable.editionForm.reference = 'REF-MOD-001'
|
||||||
|
composable.editionForm.prix = '2500'
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
name: 'Moteur modifi\u00e9',
|
||||||
|
description: 'Nouvelle description',
|
||||||
|
reference: 'REF-MOD-001',
|
||||||
|
prix: '2500',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves custom fields after patch', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('patches slot edits to correct endpoints', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockPatch.mockResolvedValue({ success: true, data: {} })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Make slot edits
|
||||||
|
composable.setPieceSlotSelection('ps-001', 'piece-new')
|
||||||
|
composable.setSlotQuantity('ps-002', 3)
|
||||||
|
composable.setProductSlotSelection('prs-001', 'prod-new')
|
||||||
|
composable.setSubcomponentSlotSelection('scs-001', 'comp-new')
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
// Verify piece slot patches
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-new' })
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-002', { quantity: 3 })
|
||||||
|
|
||||||
|
// Verify product slot patch
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composant-product-slots/prs-001', { selectedProductId: 'prod-new' })
|
||||||
|
|
||||||
|
// Verify subcomponent slot patch
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composant-subcomponent-slots/scs-001', { selectedComposantId: 'comp-new' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs constructeur links with diff', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Add a second constructeur link
|
||||||
|
composable.constructeurLinks.value = [
|
||||||
|
{ ...mockLinkSKF },
|
||||||
|
{ ...mockLinkFAG },
|
||||||
|
]
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
// originalConstructeurLinks was set to [mockLinkSKF] from fetchLinks
|
||||||
|
// formLinks is now [mockLinkSKF, mockLinkFAG]
|
||||||
|
const [entityType, entityId, origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
||||||
|
expect(entityType).toBe('composant')
|
||||||
|
expect(entityId).toBe(COMPONENT_ID)
|
||||||
|
expect(origLinks).toHaveLength(1)
|
||||||
|
expect(formLinks).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editing name does not lose constructeur links', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Only edit name
|
||||||
|
composable.editionForm.name = 'Nouveau nom moteur'
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
// updateComposant was called with name change
|
||||||
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.name).toBe('Nouveau nom moteur')
|
||||||
|
|
||||||
|
// syncLinks was still called (preserving links)
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
||||||
|
// Both should contain the original SKF link
|
||||||
|
expect(origLinks).toHaveLength(1)
|
||||||
|
expect(formLinks).toHaveLength(1)
|
||||||
|
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Document operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('document operations', () => {
|
||||||
|
it('populates componentDocuments from fetchComponent response', async () => {
|
||||||
|
const docFixtures = [
|
||||||
|
{ id: 'doc-1', name: 'photo.jpg', type: 'photo' },
|
||||||
|
{ id: 'doc-2', name: 'schema.pdf', type: 'schema' },
|
||||||
|
]
|
||||||
|
const composable = await createAndHydrate({ documents: docFixtures } as any)
|
||||||
|
|
||||||
|
expect(composable.componentDocuments.value).toHaveLength(2)
|
||||||
|
expect(composable.componentDocuments.value[0].id).toBe('doc-1')
|
||||||
|
expect(composable.componentDocuments.value[1].id).toBe('doc-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets componentDocuments to empty array when response has no documents', async () => {
|
||||||
|
const composable = await createAndHydrate({ documents: undefined } as any)
|
||||||
|
|
||||||
|
expect(composable.componentDocuments.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleFilesAdded calls uploadDocuments with composantId context', async () => {
|
||||||
|
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
|
||||||
|
mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: [] })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
const files = [new File(['content'], 'test.pdf', { type: 'application/pdf' })]
|
||||||
|
await composable.handleFilesAdded(files)
|
||||||
|
|
||||||
|
expect(mockUploadDocuments).toHaveBeenCalledTimes(1)
|
||||||
|
const callArgs = mockUploadDocuments.mock.calls[0]![0]
|
||||||
|
expect(callArgs.files).toBe(files)
|
||||||
|
expect(callArgs.context.composantId).toBe(COMPONENT_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleFilesAdded does nothing when files array is empty', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.handleFilesAdded([])
|
||||||
|
|
||||||
|
expect(mockUploadDocuments).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleFilesAdded refreshes documents after successful upload', async () => {
|
||||||
|
const refreshedDocs = [{ id: 'doc-new', name: 'uploaded.pdf' }]
|
||||||
|
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
|
||||||
|
mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: refreshedDocs })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
const files = [new File(['data'], 'uploaded.pdf')]
|
||||||
|
await composable.handleFilesAdded(files)
|
||||||
|
|
||||||
|
expect(mockLoadDocumentsByComponent).toHaveBeenCalledWith(COMPONENT_ID, { updateStore: false })
|
||||||
|
expect(composable.componentDocuments.value).toHaveLength(1)
|
||||||
|
expect(composable.componentDocuments.value[0].id).toBe('doc-new')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeDocument calls deleteDocument and removes from local list', async () => {
|
||||||
|
mockDeleteDocument.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate({
|
||||||
|
documents: [
|
||||||
|
{ id: 'doc-a', name: 'a.pdf' },
|
||||||
|
{ id: 'doc-b', name: 'b.pdf' },
|
||||||
|
],
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(composable.componentDocuments.value).toHaveLength(2)
|
||||||
|
|
||||||
|
await composable.removeDocument('doc-a')
|
||||||
|
|
||||||
|
expect(mockDeleteDocument).toHaveBeenCalledWith('doc-a', { updateStore: false })
|
||||||
|
expect(composable.componentDocuments.value).toHaveLength(1)
|
||||||
|
expect(composable.componentDocuments.value[0].id).toBe('doc-b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeDocument does nothing when documentId is falsy', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.removeDocument(null)
|
||||||
|
await composable.removeDocument(undefined)
|
||||||
|
|
||||||
|
expect(mockDeleteDocument).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Null field handling in PATCH payload
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('null field handling in PATCH payload', () => {
|
||||||
|
it('empty prix string sends null in payload', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.prix = ''
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('whitespace-only prix string sends null in payload', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.prix = ' '
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valid prix string sends stringified number', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.prix = '42.50'
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBe('42.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty reference string sends null in payload', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.reference = ''
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.reference).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty description string sends null in payload', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.description = ''
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.description).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('whitespace-only description sends null in payload', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.description = ' '
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.description).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('name is trimmed but never null', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.name = ' Moteur '
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.name).toBe('Moteur')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('error paths', () => {
|
||||||
|
it('does not save custom fields when updateComposant returns { success: false }', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: false })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.name = 'Test'
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not patch slots when updateComposant returns { success: false }', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: false })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.setPieceSlotSelection('ps-001', 'piece-new')
|
||||||
|
composable.setProductSlotSelection('prs-001', 'prod-new')
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not sync constructeur links when updateComposant fails', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: false })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when saveAllCustomFields returns failed fields', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockSaveAll.mockResolvedValue(['Tension nominale', 'Indice de protection'])
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockShowError).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockShowError.mock.calls[0]![0]).toContain('Tension nominale')
|
||||||
|
expect(mockShowError.mock.calls[0]![0]).toContain('Indice de protection')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still saves slots and syncs links even when custom fields fail', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockSaveAll.mockResolvedValue(['Tension nominale'])
|
||||||
|
mockPatch.mockResolvedValue({ success: true, data: {} })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.setPieceSlotSelection('ps-001', 'piece-after-cf-fail')
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
// Slots still patched despite custom field failure
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-after-cf-fail' })
|
||||||
|
// Links still synced
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
// Success toast still shown (alongside the error toast for CF)
|
||||||
|
expect(mockShowSuccess).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when submitEdition throws', async () => {
|
||||||
|
mockUpdateComposant.mockRejectedValue(new Error('Network failure'))
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockShowError).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockShowError.mock.calls[0]![0]).toContain('Network failure')
|
||||||
|
expect(composable.saving.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets saving flag even when updateComposant throws', async () => {
|
||||||
|
mockUpdateComposant.mockRejectedValue(new Error('Server error'))
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(composable.saving.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom field save verification
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('custom field save verification', () => {
|
||||||
|
it('saveAllCustomFields is called after successful updateComposant', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockSaveAll.mockResolvedValue([])
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockSaveAll).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show error toast when saveAll returns empty array (no failures)', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockSaveAll.mockResolvedValue([])
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
// showError should NOT have been called (only showSuccess)
|
||||||
|
expect(mockShowError).not.toHaveBeenCalled()
|
||||||
|
expect(mockShowSuccess).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error with all failed field names joined', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockSaveAll.mockResolvedValue(['Champ A', 'Champ B', 'Champ C'])
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockShowError).toHaveBeenCalledTimes(1)
|
||||||
|
const errorMsg = mockShowError.mock.calls[0]![0] as string
|
||||||
|
expect(errorMsg).toContain('Champ A')
|
||||||
|
expect(errorMsg).toContain('Champ B')
|
||||||
|
expect(errorMsg).toContain('Champ C')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitEdition does nothing when component is null', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Force component to null
|
||||||
|
composable.component.value = null
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdateComposant).not.toHaveBeenCalled()
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
157
frontend/tests/composables/useComposants.test.ts
Normal file
157
frontend/tests/composables/useComposants.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { useComposants } from '~/composables/useComposants'
|
||||||
|
import { mockComponentFromApi, wrapCollection } from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const { clearComposantsCache } = useComposants()
|
||||||
|
clearComposantsCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createComposant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('createComposant', () => {
|
||||||
|
it('sends all fields in creation payload', async () => {
|
||||||
|
const created = { ...mockComponentFromApi, id: 'comp-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createComposant } = useComposants()
|
||||||
|
const result = await createComposant({
|
||||||
|
name: 'Moteur principal',
|
||||||
|
reference: 'COMP-MOT-001',
|
||||||
|
description: 'Un moteur',
|
||||||
|
typeComposantId: 'tc-moteur',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
// normalizeRelationIds converts typeComposantId to typeComposant IRI
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/composants', expect.objectContaining({
|
||||||
|
name: 'Moteur principal',
|
||||||
|
reference: 'COMP-MOT-001',
|
||||||
|
description: 'Un moteur',
|
||||||
|
typeComposant: '/api/model_types/tc-moteur',
|
||||||
|
}))
|
||||||
|
// typeComposantId should be removed by normalizeRelationIds
|
||||||
|
const payload = mockPost.mock.calls[0]![1]
|
||||||
|
expect(payload).not.toHaveProperty('typeComposantId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds created composant to cache', async () => {
|
||||||
|
const created = { ...mockComponentFromApi, id: 'comp-new', name: 'Nouveau composant' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createComposant, composants, total } = useComposants()
|
||||||
|
|
||||||
|
expect(composants.value).toHaveLength(0)
|
||||||
|
expect(total.value).toBe(0)
|
||||||
|
|
||||||
|
await createComposant({ name: 'Nouveau composant' })
|
||||||
|
|
||||||
|
expect(composants.value).toHaveLength(1)
|
||||||
|
expect(composants.value[0]!.id).toBe('comp-new')
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// updateComposant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('updateComposant', () => {
|
||||||
|
it('patches and updates cache', async () => {
|
||||||
|
// Seed the cache with one composant
|
||||||
|
const original = { ...mockComponentFromApi, id: 'comp-001', name: 'Ancien nom' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: original })
|
||||||
|
const { createComposant, updateComposant, composants } = useComposants()
|
||||||
|
await createComposant({ name: 'Ancien nom' })
|
||||||
|
expect(composants.value).toHaveLength(1)
|
||||||
|
|
||||||
|
// Now update
|
||||||
|
const updated = { ...original, name: 'Nouveau nom' }
|
||||||
|
mockPatch.mockResolvedValue({ success: true, data: updated })
|
||||||
|
|
||||||
|
const result = await updateComposant('comp-001', { name: 'Nouveau nom' })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composants/comp-001', expect.objectContaining({
|
||||||
|
name: 'Nouveau nom',
|
||||||
|
}))
|
||||||
|
expect(composants.value[0]!.name).toBe('Nouveau nom')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteComposant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('deleteComposant', () => {
|
||||||
|
it('removes composant from cache on success', async () => {
|
||||||
|
// Seed cache
|
||||||
|
const item = { ...mockComponentFromApi, id: 'comp-del', name: 'A supprimer' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: item })
|
||||||
|
const { createComposant, deleteComposant, composants, total } = useComposants()
|
||||||
|
await createComposant({ name: 'A supprimer' })
|
||||||
|
expect(composants.value).toHaveLength(1)
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
const result = await deleteComposant('comp-del')
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(composants.value).toHaveLength(0)
|
||||||
|
expect(total.value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps composant in cache on failure', async () => {
|
||||||
|
// Seed cache
|
||||||
|
const item = { ...mockComponentFromApi, id: 'comp-keep', name: 'Garder' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: item })
|
||||||
|
const { createComposant, deleteComposant, composants, total } = useComposants()
|
||||||
|
await createComposant({ name: 'Garder' })
|
||||||
|
expect(composants.value).toHaveLength(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
|
||||||
|
const result = await deleteComposant('comp-keep')
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(composants.value).toHaveLength(1)
|
||||||
|
expect(composants.value[0]!.id).toBe('comp-keep')
|
||||||
|
})
|
||||||
|
})
|
||||||
237
frontend/tests/composables/useConstructeurLinks.test.ts
Normal file
237
frontend/tests/composables/useConstructeurLinks.test.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||||
|
import {
|
||||||
|
mockLinkSKF,
|
||||||
|
mockLinkFAG,
|
||||||
|
mockConstructeurSKF,
|
||||||
|
mockConstructeurFAG,
|
||||||
|
wrapCollection,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// fetchLinks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('fetchLinks', () => {
|
||||||
|
it('returns parsed links with all properties for composant', async () => {
|
||||||
|
const apiLinks = [
|
||||||
|
{
|
||||||
|
id: mockLinkSKF.linkId,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: mockLinkSKF.supplierReference,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mockLinkFAG.linkId,
|
||||||
|
constructeur: mockConstructeurFAG,
|
||||||
|
supplierReference: mockLinkFAG.supplierReference,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection(apiLinks) })
|
||||||
|
|
||||||
|
const { fetchLinks } = useConstructeurLinks()
|
||||||
|
const result = await fetchLinks('composant', 'comp-001')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
linkId: mockLinkSKF.linkId,
|
||||||
|
constructeurId: mockConstructeurSKF.id,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: mockLinkSKF.supplierReference,
|
||||||
|
})
|
||||||
|
expect(result[1]).toEqual({
|
||||||
|
linkId: mockLinkFAG.linkId,
|
||||||
|
constructeurId: mockConstructeurFAG.id,
|
||||||
|
constructeur: mockConstructeurFAG,
|
||||||
|
supplierReference: mockLinkFAG.supplierReference,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns supplierReference as null when absent from API', async () => {
|
||||||
|
const apiLinks = [
|
||||||
|
{
|
||||||
|
id: 'link-no-ref',
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
// no supplierReference key
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection(apiLinks) })
|
||||||
|
|
||||||
|
const { fetchLinks } = useConstructeurLinks()
|
||||||
|
const result = await fetchLinks('composant', 'comp-001')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]!.supplierReference).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['machine', '/machine_constructeur_links?machine=/api/machines/m-001', 'm-001'],
|
||||||
|
['product', '/product_constructeur_links?product=/api/products/p-001', 'p-001'],
|
||||||
|
['piece', '/piece_constructeur_links?piece=/api/pieces/pc-001', 'pc-001'],
|
||||||
|
['composant', '/composant_constructeur_links?composant=/api/composants/c-001', 'c-001'],
|
||||||
|
] as const)('uses correct endpoint for %s', async (entityType, expectedUrl, entityId) => {
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection([]) })
|
||||||
|
|
||||||
|
const { fetchLinks } = useConstructeurLinks()
|
||||||
|
await fetchLinks(entityType, entityId)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(expectedUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array on API failure', async () => {
|
||||||
|
mockGet.mockResolvedValue({ success: false, data: null })
|
||||||
|
|
||||||
|
const { fetchLinks } = useConstructeurLinks()
|
||||||
|
const result = await fetchLinks('composant', 'comp-001')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// syncLinks — 3-way diff
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('syncLinks', () => {
|
||||||
|
it('creates new links via POST', async () => {
|
||||||
|
mockPost.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [], [mockLinkSKF])
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
|
||||||
|
composant: '/api/composants/comp-001',
|
||||||
|
constructeur: `/api/constructeurs/${mockConstructeurSKF.id}`,
|
||||||
|
supplierReference: mockLinkSKF.supplierReference,
|
||||||
|
})
|
||||||
|
expect(mockDel).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes removed links via DELETE', async () => {
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [mockLinkSKF], [])
|
||||||
|
|
||||||
|
expect(mockDel).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('patches when supplierReference changes (value to new value)', async () => {
|
||||||
|
mockPatch.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const updatedLink = { ...mockLinkSKF, supplierReference: 'NEW-REF-999' }
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [mockLinkSKF], [updatedLink])
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`, {
|
||||||
|
supplierReference: 'NEW-REF-999',
|
||||||
|
})
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockDel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('patches when supplierReference changes from value to null', async () => {
|
||||||
|
mockPatch.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const updatedLink = { ...mockLinkSKF, supplierReference: null }
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [mockLinkSKF], [updatedLink])
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`, {
|
||||||
|
supplierReference: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when links are identical (no API calls)', async () => {
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [mockLinkSKF], [mockLinkSKF])
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockDel).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles add + delete in same operation', async () => {
|
||||||
|
mockPost.mockResolvedValue({ success: true })
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [mockLinkSKF], [mockLinkFAG])
|
||||||
|
|
||||||
|
expect(mockDel).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`)
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
|
||||||
|
composant: '/api/composants/comp-001',
|
||||||
|
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
|
||||||
|
supplierReference: mockLinkFAG.supplierReference,
|
||||||
|
})
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty original and empty form (no-op)', async () => {
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [], [])
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockDel).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends supplierReference as null when empty string', async () => {
|
||||||
|
mockPost.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const linkWithEmpty = { ...mockLinkFAG, supplierReference: '' }
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [], [linkWithEmpty])
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
|
||||||
|
composant: '/api/composants/comp-001',
|
||||||
|
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
|
||||||
|
supplierReference: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['machine', '/machine_constructeur_links', 'machine', '/api/machines/m-001', 'm-001'],
|
||||||
|
['product', '/product_constructeur_links', 'product', '/api/products/p-001', 'p-001'],
|
||||||
|
['piece', '/piece_constructeur_links', 'piece', '/api/pieces/pc-001', 'pc-001'],
|
||||||
|
['composant', '/composant_constructeur_links', 'composant', '/api/composants/c-001', 'c-001'],
|
||||||
|
] as const)('uses correct endpoint and entity IRI for %s', async (entityType, endpoint, key, entityIri, entityId) => {
|
||||||
|
mockPost.mockResolvedValue({ success: true })
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks(entityType, entityId, [mockLinkSKF], [mockLinkFAG])
|
||||||
|
|
||||||
|
expect(mockDel).toHaveBeenCalledWith(`${endpoint}/${mockLinkSKF.linkId}`)
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(endpoint, {
|
||||||
|
[key]: entityIri,
|
||||||
|
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
|
||||||
|
supplierReference: mockLinkFAG.supplierReference,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
475
frontend/tests/composables/useCustomFieldInputs.test.ts
Normal file
475
frontend/tests/composables/useCustomFieldInputs.test.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
|
||||||
|
import {
|
||||||
|
shouldPersist,
|
||||||
|
formatValueForSave,
|
||||||
|
} from '~/shared/utils/customFields'
|
||||||
|
import {
|
||||||
|
mockCustomFieldDefs,
|
||||||
|
mockCustomFieldValues,
|
||||||
|
mockMachineCustomFieldDefs,
|
||||||
|
mockMachineCustomFieldValues,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUpdateCustomFieldValue = vi.fn()
|
||||||
|
const mockUpsertCustomFieldValue = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFields', () => ({
|
||||||
|
useCustomFields: () => ({
|
||||||
|
updateCustomFieldValue: mockUpdateCustomFieldValue,
|
||||||
|
upsertCustomFieldValue: mockUpsertCustomFieldValue,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockUpdateCustomFieldValue.mockResolvedValue({ success: true })
|
||||||
|
mockUpsertCustomFieldValue.mockResolvedValue({ success: true, data: { id: 'new-cfv-id', customField: { id: 'new-cf-id' } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Field initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('field initialization', () => {
|
||||||
|
it('merges all definitions with their values (6 defs → 6 allFields, 5 standalone fields)', () => {
|
||||||
|
const { fields, allFields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(allFields.value).toHaveLength(6)
|
||||||
|
expect(fields.value).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for number type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const numberField = fields.value.find(f => f.name === 'Tension nominale')
|
||||||
|
expect(numberField?.value).toBe('220')
|
||||||
|
expect(numberField?.type).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for boolean type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'Certifié CE')
|
||||||
|
expect(boolField?.value).toBe('true')
|
||||||
|
expect(boolField?.type).toBe('boolean')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for select type with options', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Indice de protection')
|
||||||
|
expect(selectField?.value).toBe('IP65')
|
||||||
|
expect(selectField?.type).toBe('select')
|
||||||
|
expect(selectField?.options).toEqual(['IP54', 'IP55', 'IP65'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for date type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date de calibration')
|
||||||
|
expect(dateField?.value).toBe('2025-06-15')
|
||||||
|
expect(dateField?.type).toBe('date')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for text type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const textField = fields.value.find(f => f.name === 'Remarques techniques')
|
||||||
|
expect(textField?.value).toBe('Roulement renforcé pour environnement humide')
|
||||||
|
expect(textField?.type).toBe('text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses defaultValue when no persisted value exists', () => {
|
||||||
|
// Pass empty values array so all fields use defaultValue
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const numberField = fields.value.find(f => f.name === 'Tension nominale')
|
||||||
|
expect(numberField?.value).toBe('220')
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'Certifié CE')
|
||||||
|
expect(boolField?.value).toBe('false')
|
||||||
|
|
||||||
|
// No defaultValue → empty string
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date de calibration')
|
||||||
|
expect(dateField?.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters machineContextOnly in standalone context (allFields=6, fields=5)', () => {
|
||||||
|
const { fields, allFields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(allFields.value).toHaveLength(6)
|
||||||
|
expect(fields.value).toHaveLength(5)
|
||||||
|
expect(fields.value.every(f => !f.machineContextOnly)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows only machineContextOnly in machine context (1 field)', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'machine',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fields.value).toHaveLength(1)
|
||||||
|
expect(fields.value[0]?.name).toBe('Position sur machine')
|
||||||
|
expect(fields.value[0]?.machineContextOnly).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Boolean — the tricky case
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('boolean — the tricky case', () => {
|
||||||
|
it('saves "false" value via update (not ignored)', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'Certifié CE')!
|
||||||
|
boolField.value = 'false'
|
||||||
|
|
||||||
|
await update(boolField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists boolean "false" in saveAll (not skipped)', async () => {
|
||||||
|
// Only provide the boolean field def + value
|
||||||
|
const boolDef = mockCustomFieldDefs[1]!
|
||||||
|
const boolVal = { ...mockCustomFieldValues[1]!, value: 'false' }
|
||||||
|
|
||||||
|
const { fields, saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref([boolDef]),
|
||||||
|
values: ref([boolVal]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fields.value[0]?.value).toBe('false')
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Number zero
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('number zero', () => {
|
||||||
|
it('saves "0" value (not ignored)', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockMachineCustomFieldDefs),
|
||||||
|
values: ref(mockMachineCustomFieldValues),
|
||||||
|
entityType: 'machine',
|
||||||
|
entityId: ref('cl-machine-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const numField = fields.value.find(f => f.name === 'Puissance (kW)')!
|
||||||
|
expect(numField.value).toBe('0')
|
||||||
|
|
||||||
|
await update(numField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-003', { value: '0' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Text empty string
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('text empty string', () => {
|
||||||
|
it('shouldPersist returns false for empty trimmed string', () => {
|
||||||
|
const field = {
|
||||||
|
customFieldId: 'cf-1',
|
||||||
|
customFieldValueId: null,
|
||||||
|
name: 'Notes',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
value: ' ',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(shouldPersist(field)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists non-empty text value', () => {
|
||||||
|
const field = {
|
||||||
|
customFieldId: 'cf-1',
|
||||||
|
customFieldValueId: null,
|
||||||
|
name: 'Notes',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
value: 'some text',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(shouldPersist(field)).toBe(true)
|
||||||
|
expect(formatValueForSave(field)).toBe('some text')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Select
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('select', () => {
|
||||||
|
it('saves changed option value', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Indice de protection')!
|
||||||
|
selectField.value = 'IP55'
|
||||||
|
|
||||||
|
await update(selectField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-003', { value: 'IP55' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Date
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('date', () => {
|
||||||
|
it('saves date value in correct format', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date de calibration')!
|
||||||
|
dateField.value = '2026-01-20'
|
||||||
|
|
||||||
|
await update(dateField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-004', { value: '2026-01-20' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// saveAll isolation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('saveAll isolation', () => {
|
||||||
|
it('saves all fields independently without losing values', async () => {
|
||||||
|
const { fields, saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Modify one field
|
||||||
|
const numberField = fields.value.find(f => f.name === 'Tension nominale')!
|
||||||
|
numberField.value = '380'
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
|
||||||
|
// All persistable fields should have been saved
|
||||||
|
// 5 fields in standalone context, all have values
|
||||||
|
expect(mockUpdateCustomFieldValue.mock.calls.length).toBeGreaterThanOrEqual(4)
|
||||||
|
|
||||||
|
// The modified field should have the new value
|
||||||
|
const numberCall = mockUpdateCustomFieldValue.mock.calls.find(
|
||||||
|
(c: any[]) => c[0] === 'cfv-001',
|
||||||
|
)
|
||||||
|
expect(numberCall?.[1]).toEqual({ value: '380' })
|
||||||
|
|
||||||
|
// Another field should still have its original value
|
||||||
|
const boolCall = mockUpdateCustomFieldValue.mock.calls.find(
|
||||||
|
(c: any[]) => c[0] === 'cfv-002',
|
||||||
|
)
|
||||||
|
expect(boolCall?.[1]).toEqual({ value: 'true' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upserts new value when no customFieldValueId exists', async () => {
|
||||||
|
// Use defs without matching values — no customFieldValueId
|
||||||
|
const defs = [mockCustomFieldDefs[0]!]
|
||||||
|
|
||||||
|
const { saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
|
||||||
|
// Should use upsert since no customFieldValueId
|
||||||
|
expect(mockUpsertCustomFieldValue).toHaveBeenCalledWith(
|
||||||
|
'cf-def-001',
|
||||||
|
'composant',
|
||||||
|
'cl-comp-1',
|
||||||
|
'220',
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns failed field names on error', async () => {
|
||||||
|
mockUpdateCustomFieldValue.mockResolvedValueOnce({ success: false })
|
||||||
|
|
||||||
|
const defs = [mockCustomFieldDefs[0]!]
|
||||||
|
const vals = [mockCustomFieldValues[0]!]
|
||||||
|
|
||||||
|
const { saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref(vals),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual(['Tension nominale'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// requiredFilled validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('requiredFilled validation', () => {
|
||||||
|
it('returns true when required fields have values (including defaultValue)', () => {
|
||||||
|
const { requiredFilled } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
// "Tension nominale" is required and has value '220'
|
||||||
|
expect(requiredFilled.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when required field uses defaultValue', () => {
|
||||||
|
// No values provided — required field should use defaultValue '220'
|
||||||
|
const { requiredFilled } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requiredFilled.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when required field has no value and no default', () => {
|
||||||
|
// Create a required field with no default and no value
|
||||||
|
const defs = [{
|
||||||
|
id: 'cf-required-no-default',
|
||||||
|
name: 'Required Field',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
}]
|
||||||
|
|
||||||
|
const { requiredFilled } = useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requiredFilled.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
319
frontend/tests/composables/useDocuments.test.ts
Normal file
319
frontend/tests/composables/useDocuments.test.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { wrapCollection } from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — API layer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockPostFormData = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: vi.fn(),
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: mockPostFormData,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — Toast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockShowSuccess = vi.fn()
|
||||||
|
const mockShowError = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: mockShowSuccess,
|
||||||
|
showError: mockShowError,
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import under test (AFTER all vi.mock calls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockDocument = {
|
||||||
|
id: 'doc-001',
|
||||||
|
name: 'photo.jpg',
|
||||||
|
filename: 'photo.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
size: 12345,
|
||||||
|
fileUrl: '/files/photo.jpg',
|
||||||
|
downloadUrl: '/files/photo.jpg/download',
|
||||||
|
createdAt: '2025-01-10T08:00:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDocument2 = {
|
||||||
|
id: 'doc-002',
|
||||||
|
name: 'schema.pdf',
|
||||||
|
filename: 'schema.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
size: 54321,
|
||||||
|
fileUrl: '/files/schema.pdf',
|
||||||
|
downloadUrl: '/files/schema.pdf/download',
|
||||||
|
createdAt: '2025-01-11T09:00:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createMockFile(name: string, type = 'image/jpeg'): File {
|
||||||
|
return new File(['content'], name, { type })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// beforeEach
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// uploadDocuments — FormData is built correctly
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('uploadDocuments', () => {
|
||||||
|
it('builds FormData with file and context fields', async () => {
|
||||||
|
mockPostFormData.mockResolvedValue({ success: true, data: mockDocument })
|
||||||
|
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const file = createMockFile('photo.jpg')
|
||||||
|
|
||||||
|
await uploadDocuments({
|
||||||
|
files: [file],
|
||||||
|
context: { pieceId: 'piece-001', composantId: 'comp-001' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPostFormData).toHaveBeenCalledTimes(1)
|
||||||
|
const [endpoint, formData] = mockPostFormData.mock.calls[0]!
|
||||||
|
expect(endpoint).toBe('/documents')
|
||||||
|
|
||||||
|
expect(formData).toBeInstanceOf(FormData)
|
||||||
|
expect(formData.get('file')).toBe(file)
|
||||||
|
expect(formData.get('name')).toBe('photo.jpg')
|
||||||
|
expect(formData.get('pieceId')).toBe('piece-001')
|
||||||
|
expect(formData.get('composantId')).toBe('comp-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads multiple files separately', async () => {
|
||||||
|
mockPostFormData
|
||||||
|
.mockResolvedValueOnce({ success: true, data: mockDocument })
|
||||||
|
.mockResolvedValueOnce({ success: true, data: mockDocument2 })
|
||||||
|
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const file1 = createMockFile('photo.jpg')
|
||||||
|
const file2 = createMockFile('schema.pdf', 'application/pdf')
|
||||||
|
|
||||||
|
const result = await uploadDocuments({
|
||||||
|
files: [file1, file2],
|
||||||
|
context: { machineId: 'machine-001' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPostFormData).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
// First call
|
||||||
|
const [, formData1] = mockPostFormData.mock.calls[0]!
|
||||||
|
expect(formData1.get('name')).toBe('photo.jpg')
|
||||||
|
expect(formData1.get('machineId')).toBe('machine-001')
|
||||||
|
|
||||||
|
// Second call
|
||||||
|
const [, formData2] = mockPostFormData.mock.calls[1]!
|
||||||
|
expect(formData2.get('name')).toBe('schema.pdf')
|
||||||
|
expect(formData2.get('machineId')).toBe('machine-001')
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(Array.isArray(result.data) ? result.data : []).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends type to FormData when provided in context', async () => {
|
||||||
|
mockPostFormData.mockResolvedValue({ success: true, data: mockDocument })
|
||||||
|
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const file = createMockFile('facture.pdf', 'application/pdf')
|
||||||
|
|
||||||
|
await uploadDocuments({
|
||||||
|
files: [file],
|
||||||
|
context: { siteId: 'site-001', type: 'facture' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const [, formData] = mockPostFormData.mock.calls[0]!
|
||||||
|
expect(formData.get('type')).toBe('facture')
|
||||||
|
expect(formData.get('siteId')).toBe('site-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when no files provided', async () => {
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
|
||||||
|
const result = await uploadDocuments({ files: [], context: {} })
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(mockPostFormData).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadDocumentsByComponent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('loadDocumentsByComponent', () => {
|
||||||
|
it('calls correct endpoint /documents/composant/{id}', async () => {
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
|
||||||
|
|
||||||
|
const { loadDocumentsByComponent } = useDocuments()
|
||||||
|
const result = await loadDocumentsByComponent('comp-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/documents/composant/comp-001')
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for empty componentId', async () => {
|
||||||
|
const { loadDocumentsByComponent } = useDocuments()
|
||||||
|
const result = await loadDocumentsByComponent('')
|
||||||
|
|
||||||
|
expect(mockGet).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadDocumentsByPiece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('loadDocumentsByPiece', () => {
|
||||||
|
it('calls correct endpoint /documents/piece/{id}', async () => {
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
|
||||||
|
|
||||||
|
const { loadDocumentsByPiece } = useDocuments()
|
||||||
|
const result = await loadDocumentsByPiece('piece-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/documents/piece/piece-001')
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for empty pieceId', async () => {
|
||||||
|
const { loadDocumentsByPiece } = useDocuments()
|
||||||
|
const result = await loadDocumentsByPiece('')
|
||||||
|
|
||||||
|
expect(mockGet).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadDocumentsByMachine
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('loadDocumentsByMachine', () => {
|
||||||
|
it('calls correct endpoint /documents/machine/{id}', async () => {
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
|
||||||
|
|
||||||
|
const { loadDocumentsByMachine } = useDocuments()
|
||||||
|
const result = await loadDocumentsByMachine('machine-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/documents/machine/machine-001')
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for empty machineId', async () => {
|
||||||
|
const { loadDocumentsByMachine } = useDocuments()
|
||||||
|
const result = await loadDocumentsByMachine('')
|
||||||
|
|
||||||
|
expect(mockGet).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadDocumentsByProduct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('loadDocumentsByProduct', () => {
|
||||||
|
it('calls correct endpoint /documents/product/{id}', async () => {
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
|
||||||
|
|
||||||
|
const { loadDocumentsByProduct } = useDocuments()
|
||||||
|
const result = await loadDocumentsByProduct('prod-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/documents/product/prod-001')
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for empty productId', async () => {
|
||||||
|
const { loadDocumentsByProduct } = useDocuments()
|
||||||
|
const result = await loadDocumentsByProduct('')
|
||||||
|
|
||||||
|
expect(mockGet).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteDocument
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('deleteDocument', () => {
|
||||||
|
it('calls DELETE on correct endpoint', async () => {
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { deleteDocument } = useDocuments()
|
||||||
|
const result = await deleteDocument('doc-001')
|
||||||
|
|
||||||
|
expect(mockDel).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockDel).toHaveBeenCalledWith('/documents/doc-001')
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes from store when updateStore is true', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: wrapCollection([mockDocument, mockDocument2]),
|
||||||
|
})
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { loadDocuments, deleteDocument, documents } = useDocuments()
|
||||||
|
|
||||||
|
// Load documents into store first
|
||||||
|
await loadDocuments({ force: true })
|
||||||
|
|
||||||
|
expect(documents.value).toHaveLength(2)
|
||||||
|
|
||||||
|
// Delete with updateStore: true
|
||||||
|
await deleteDocument('doc-001', { updateStore: true })
|
||||||
|
|
||||||
|
expect(documents.value).toHaveLength(1)
|
||||||
|
expect(documents.value[0]!.id).toBe('doc-002')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success toast on successful delete', async () => {
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { deleteDocument } = useDocuments()
|
||||||
|
await deleteDocument('doc-001')
|
||||||
|
|
||||||
|
expect(mockShowSuccess).toHaveBeenCalledWith('Document supprimé')
|
||||||
|
})
|
||||||
|
})
|
||||||
267
frontend/tests/composables/useMachineDetailCustomFields.test.ts
Normal file
267
frontend/tests/composables/useMachineDetailCustomFields.test.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
|
||||||
|
import {
|
||||||
|
mockMachineCustomFieldDefs,
|
||||||
|
mockMachineCustomFieldValues,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUpdateCustomFieldValue = vi.fn()
|
||||||
|
const mockUpsertCustomFieldValue = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFields', () => ({
|
||||||
|
useCustomFields: () => ({
|
||||||
|
updateCustomFieldValue: mockUpdateCustomFieldValue,
|
||||||
|
upsertCustomFieldValue: mockUpsertCustomFieldValue,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockUpdateCustomFieldValue.mockResolvedValue({ success: true })
|
||||||
|
mockUpsertCustomFieldValue.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: { id: 'new-mcfv-id', customField: { id: 'new-mcf-id' } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper — create composable with machine context (no context filter)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createMachineFields(
|
||||||
|
defs = mockMachineCustomFieldDefs,
|
||||||
|
vals = mockMachineCustomFieldValues,
|
||||||
|
entityId = 'cl-machine-1',
|
||||||
|
) {
|
||||||
|
return useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref(vals),
|
||||||
|
entityType: 'machine',
|
||||||
|
entityId: ref(entityId),
|
||||||
|
// No context — machine custom fields don't use machineContextOnly filtering
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Machine custom field initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('machine custom field initialization', () => {
|
||||||
|
it('loads all machine custom fields with values (5 fields)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
expect(fields.value).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves text value (Numéro de série)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const textField = fields.value.find(f => f.name === 'Numéro de série')
|
||||||
|
expect(textField?.value).toBe('SN-2025-001234')
|
||||||
|
expect(textField?.type).toBe('text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves boolean value (En service = true)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'En service')
|
||||||
|
expect(boolField?.value).toBe('true')
|
||||||
|
expect(boolField?.type).toBe('boolean')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves number zero value (Puissance kW = 0)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const numField = fields.value.find(f => f.name === 'Puissance (kW)')
|
||||||
|
expect(numField?.value).toBe('0')
|
||||||
|
expect(numField?.type).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves select value (Catégorie ATEX = Zone 1)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')
|
||||||
|
expect(selectField?.value).toBe('Zone 1')
|
||||||
|
expect(selectField?.type).toBe('select')
|
||||||
|
expect(selectField?.options).toEqual(['Zone 0', 'Zone 1', 'Zone 2', 'Non classé'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves date value (Date mise en service = 2025-01-15)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date mise en service')
|
||||||
|
expect(dateField?.value).toBe('2025-01-15')
|
||||||
|
expect(dateField?.type).toBe('date')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Boolean checkbox — the critical test
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('boolean checkbox — the critical test', () => {
|
||||||
|
it('toggle true to false sends "false" (not deleted) via update()', async () => {
|
||||||
|
const { fields, update } = createMachineFields()
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'En service')!
|
||||||
|
expect(boolField.value).toBe('true')
|
||||||
|
|
||||||
|
// Toggle to false
|
||||||
|
boolField.value = 'false'
|
||||||
|
await update(boolField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'false' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggle false to true sends "true"', async () => {
|
||||||
|
// Start with boolean value = false
|
||||||
|
const falseVal = { ...mockMachineCustomFieldValues[1]!, value: 'false' }
|
||||||
|
const vals = mockMachineCustomFieldValues.map((v, i) => (i === 1 ? falseVal : v))
|
||||||
|
|
||||||
|
const { fields, update } = createMachineFields(mockMachineCustomFieldDefs, vals)
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'En service')!
|
||||||
|
expect(boolField.value).toBe('false')
|
||||||
|
|
||||||
|
// Toggle to true
|
||||||
|
boolField.value = 'true'
|
||||||
|
await update(boolField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'true' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boolean false is persisted in saveAll (not skipped)', async () => {
|
||||||
|
// Only the boolean field with value "false"
|
||||||
|
const boolDef = mockMachineCustomFieldDefs[1]!
|
||||||
|
const boolVal = { ...mockMachineCustomFieldValues[1]!, value: 'false' }
|
||||||
|
|
||||||
|
const { fields, saveAll } = createMachineFields([boolDef], [boolVal])
|
||||||
|
|
||||||
|
expect(fields.value[0]?.value).toBe('false')
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'false' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Number zero — not lost
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('number zero — not lost', () => {
|
||||||
|
it('preserves zero value after load', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const numField = fields.value.find(f => f.name === 'Puissance (kW)')!
|
||||||
|
expect(numField.value).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves zero value (not skipped) in saveAll', async () => {
|
||||||
|
// Only the number field with value "0"
|
||||||
|
const numDef = mockMachineCustomFieldDefs[2]!
|
||||||
|
const numVal = mockMachineCustomFieldValues[2]!
|
||||||
|
|
||||||
|
const { saveAll } = createMachineFields([numDef], [numVal])
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-003', { value: '0' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Select field
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('select field', () => {
|
||||||
|
it('preserves selected option', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')!
|
||||||
|
expect(selectField.value).toBe('Zone 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses defaultValue when no value exists', () => {
|
||||||
|
// Use defs with a select that has a defaultValue
|
||||||
|
const defsWithDefault = mockMachineCustomFieldDefs.map((d, i) =>
|
||||||
|
i === 3 ? { ...d, defaultValue: 'Non classé' } : d,
|
||||||
|
)
|
||||||
|
|
||||||
|
// No values for the select field
|
||||||
|
const valsWithoutSelect = mockMachineCustomFieldValues.filter(
|
||||||
|
v => v.customField.name !== 'Catégorie ATEX',
|
||||||
|
)
|
||||||
|
|
||||||
|
const { fields } = createMachineFields(defsWithDefault, valsWithoutSelect)
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')!
|
||||||
|
expect(selectField.value).toBe('Non classé')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Field isolation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('field isolation', () => {
|
||||||
|
it('updating one field does not change other field values', async () => {
|
||||||
|
const { fields, update } = createMachineFields()
|
||||||
|
|
||||||
|
// Snapshot original values
|
||||||
|
const originalValues = fields.value.map(f => ({ name: f.name, value: f.value }))
|
||||||
|
|
||||||
|
// Update only the text field
|
||||||
|
const textField = fields.value.find(f => f.name === 'Numéro de série')!
|
||||||
|
textField.value = 'SN-UPDATED-999'
|
||||||
|
await update(textField)
|
||||||
|
|
||||||
|
// All other fields should still have their original values
|
||||||
|
for (const field of fields.value) {
|
||||||
|
if (field.name === 'Numéro de série') continue
|
||||||
|
const original = originalValues.find(o => o.name === field.name)
|
||||||
|
expect(field.value).toBe(original?.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saveAll preserves all field values even on partial failure', async () => {
|
||||||
|
// Make the second call fail (boolean field)
|
||||||
|
mockUpdateCustomFieldValue
|
||||||
|
.mockResolvedValueOnce({ success: true }) // text — Numéro de série
|
||||||
|
.mockResolvedValueOnce({ success: false }) // boolean — En service
|
||||||
|
.mockResolvedValue({ success: true }) // rest succeed
|
||||||
|
|
||||||
|
const { fields, saveAll } = createMachineFields()
|
||||||
|
|
||||||
|
// Snapshot values before saveAll
|
||||||
|
const valuesBefore = fields.value.map(f => ({ name: f.name, value: f.value }))
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
|
||||||
|
// Only the boolean field should have failed
|
||||||
|
expect(failed).toEqual(['En service'])
|
||||||
|
|
||||||
|
// All field values should still be intact (not cleared or corrupted)
|
||||||
|
for (const field of fields.value) {
|
||||||
|
const before = valuesBefore.find(v => v.name === field.name)
|
||||||
|
expect(field.value).toBe(before?.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
700
frontend/tests/composables/useMachineDetailData.test.ts
Normal file
700
frontend/tests/composables/useMachineDetailData.test.ts
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock data — realistic /machines/{id}/structure response
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MACHINE_ID = 'cl-machine-abc123'
|
||||||
|
const SITE_ID = 'cl-site-nord-001'
|
||||||
|
const COMPONENT_LINK_ID = 'cl-mcl-001'
|
||||||
|
const PIECE_LINK_ID = 'cl-mpl-001'
|
||||||
|
const PRODUCT_LINK_ID = 'cl-mprl-001'
|
||||||
|
const COMPOSANT_ID = 'cl-comp-moteur-001'
|
||||||
|
const PIECE_ID = 'cl-piece-roul-001'
|
||||||
|
const PRODUCT_ID = 'cl-prod-graisse-001'
|
||||||
|
const CONSTRUCTEUR_ID = 'cstr-skf-001'
|
||||||
|
|
||||||
|
const mockConstructeurSKF = {
|
||||||
|
id: CONSTRUCTEUR_ID,
|
||||||
|
name: 'SKF',
|
||||||
|
email: 'contact@skf.com',
|
||||||
|
phone: '+33 1 23 45 67 89',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockStructureResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
machine: {
|
||||||
|
id: MACHINE_ID,
|
||||||
|
name: 'Presse hydraulique PH-200',
|
||||||
|
reference: 'MACH-PH-200',
|
||||||
|
prix: 150000,
|
||||||
|
siteId: SITE_ID,
|
||||||
|
site: { id: SITE_ID, name: 'Usine Nord' },
|
||||||
|
documents: [{ id: 'doc-001', name: 'Manuel PH-200.pdf', type: 'manual' }],
|
||||||
|
customFieldValues: [
|
||||||
|
{
|
||||||
|
id: 'mcfv-001',
|
||||||
|
value: 'SN-2025-PH200',
|
||||||
|
customField: {
|
||||||
|
id: 'mcf-001',
|
||||||
|
name: 'Serial Number',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFields: [
|
||||||
|
{
|
||||||
|
id: 'mcf-001',
|
||||||
|
name: 'Serial Number',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
constructeurs: [
|
||||||
|
{
|
||||||
|
id: 'cl-mconst-001',
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: 'SKF-PH200',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
componentLinks: [
|
||||||
|
{
|
||||||
|
id: COMPONENT_LINK_ID,
|
||||||
|
composant: {
|
||||||
|
id: COMPOSANT_ID,
|
||||||
|
name: 'Moteur principal',
|
||||||
|
reference: 'COMP-MOT-001',
|
||||||
|
prix: 12500,
|
||||||
|
typeComposant: { id: 'tc-moteur', name: 'Moteur electrique' },
|
||||||
|
constructeurs: [mockConstructeurSKF],
|
||||||
|
constructeurIds: [CONSTRUCTEUR_ID],
|
||||||
|
documents: [],
|
||||||
|
customFields: [
|
||||||
|
{
|
||||||
|
definitionId: 'cf-comp-001',
|
||||||
|
name: 'Tension nominale',
|
||||||
|
type: 'number',
|
||||||
|
value: '380',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFieldValues: [],
|
||||||
|
},
|
||||||
|
overrides: {
|
||||||
|
name: 'Moteur principal PH-200',
|
||||||
|
reference: 'COMP-MOT-PH200',
|
||||||
|
prix: 13000,
|
||||||
|
},
|
||||||
|
contextCustomFields: [
|
||||||
|
{
|
||||||
|
id: 'ctx-cf-001',
|
||||||
|
name: 'Position sur machine',
|
||||||
|
type: 'text',
|
||||||
|
machineContextOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contextCustomFieldValues: [
|
||||||
|
{
|
||||||
|
id: 'ctx-cfv-001',
|
||||||
|
value: 'Bloc moteur gauche',
|
||||||
|
customField: {
|
||||||
|
id: 'ctx-cf-001',
|
||||||
|
name: 'Position sur machine',
|
||||||
|
type: 'text',
|
||||||
|
machineContextOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pieceLinks: [
|
||||||
|
{
|
||||||
|
id: PIECE_LINK_ID,
|
||||||
|
piece: {
|
||||||
|
id: PIECE_ID,
|
||||||
|
name: 'Roulement 6205',
|
||||||
|
reference: 'ROUL-6205',
|
||||||
|
prix: 45.90,
|
||||||
|
typePiece: { id: 'tp-bearing', name: 'Roulement' },
|
||||||
|
constructeurs: [mockConstructeurSKF],
|
||||||
|
documents: [],
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
|
overrides: {
|
||||||
|
name: 'Roulement 6205-RS',
|
||||||
|
},
|
||||||
|
quantity: 2,
|
||||||
|
parentComponentLinkId: COMPONENT_LINK_ID,
|
||||||
|
contextCustomFields: [],
|
||||||
|
contextCustomFieldValues: [
|
||||||
|
{
|
||||||
|
id: 'ctx-cfv-piece-001',
|
||||||
|
value: 'Cote entrainement',
|
||||||
|
customField: {
|
||||||
|
id: 'ctx-cf-piece-001',
|
||||||
|
name: 'Emplacement',
|
||||||
|
type: 'text',
|
||||||
|
machineContextOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
childLinks: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pieceLinks: [],
|
||||||
|
productLinks: [
|
||||||
|
{
|
||||||
|
id: PRODUCT_LINK_ID,
|
||||||
|
product: {
|
||||||
|
id: PRODUCT_ID,
|
||||||
|
name: 'Graisse LGMT2',
|
||||||
|
reference: 'LUB-LGMT2',
|
||||||
|
prix: 45.90,
|
||||||
|
},
|
||||||
|
overrides: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response with NO overrides — for fallback testing
|
||||||
|
const mockStructureNoOverrides = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
machine: {
|
||||||
|
...mockStructureResponse.data.machine,
|
||||||
|
},
|
||||||
|
componentLinks: [
|
||||||
|
{
|
||||||
|
id: COMPONENT_LINK_ID,
|
||||||
|
composant: {
|
||||||
|
id: COMPOSANT_ID,
|
||||||
|
name: 'Moteur principal',
|
||||||
|
reference: 'COMP-MOT-001',
|
||||||
|
prix: 12500,
|
||||||
|
typeComposant: { id: 'tc-moteur', name: 'Moteur electrique' },
|
||||||
|
constructeurs: [],
|
||||||
|
documents: [],
|
||||||
|
customFields: [],
|
||||||
|
customFieldValues: [],
|
||||||
|
},
|
||||||
|
overrides: null,
|
||||||
|
contextCustomFields: [],
|
||||||
|
contextCustomFieldValues: [],
|
||||||
|
pieceLinks: [
|
||||||
|
{
|
||||||
|
id: PIECE_LINK_ID,
|
||||||
|
piece: {
|
||||||
|
id: PIECE_ID,
|
||||||
|
name: 'Roulement 6205',
|
||||||
|
reference: 'ROUL-6205',
|
||||||
|
prix: 45.90,
|
||||||
|
typePiece: { id: 'tp-bearing', name: 'Roulement' },
|
||||||
|
constructeurs: [],
|
||||||
|
documents: [],
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
|
overrides: null,
|
||||||
|
quantity: 1,
|
||||||
|
parentComponentLinkId: COMPONENT_LINK_ID,
|
||||||
|
contextCustomFields: [],
|
||||||
|
contextCustomFieldValues: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
childLinks: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pieceLinks: [],
|
||||||
|
productLinks: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — all composables used by useMachineDetailData
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
patch: mockPatch,
|
||||||
|
post: mockPost,
|
||||||
|
delete: mockDel,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useMachines', () => ({
|
||||||
|
useMachines: () => ({
|
||||||
|
updateMachine: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
updateStructure: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComposants', () => ({
|
||||||
|
useComposants: () => ({
|
||||||
|
updateComposant: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieces', () => ({
|
||||||
|
usePieces: () => ({
|
||||||
|
updatePiece: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComponentTypes', () => ({
|
||||||
|
useComponentTypes: () => ({
|
||||||
|
componentTypes: ref([
|
||||||
|
{ id: 'tc-moteur', name: 'Moteur electrique' },
|
||||||
|
]),
|
||||||
|
loadComponentTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieceTypes', () => ({
|
||||||
|
usePieceTypes: () => ({
|
||||||
|
pieceTypes: ref([
|
||||||
|
{ id: 'tp-bearing', name: 'Roulement' },
|
||||||
|
]),
|
||||||
|
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFields', () => ({
|
||||||
|
useCustomFields: () => ({
|
||||||
|
upsertCustomFieldValue: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
updateCustomFieldValue: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
constructeurs: ref([mockConstructeurSKF]),
|
||||||
|
loadConstructeurs: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useSites', () => ({
|
||||||
|
useSites: () => ({
|
||||||
|
sites: ref([{ id: SITE_ID, name: 'Usine Nord' }]),
|
||||||
|
loadSites: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useProducts', () => ({
|
||||||
|
useProducts: () => ({
|
||||||
|
products: ref([
|
||||||
|
{ id: PRODUCT_ID, name: 'Graisse LGMT2', reference: 'LUB-LGMT2', prix: 45.90 },
|
||||||
|
]),
|
||||||
|
loadProducts: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useDocuments', () => ({
|
||||||
|
useDocuments: () => ({
|
||||||
|
uploadDocuments: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
deleteDocument: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
loadDocumentsByMachine: vi.fn().mockResolvedValue({ success: true, data: [] }),
|
||||||
|
loadDocumentsByProduct: vi.fn().mockResolvedValue({ success: true, data: [] }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurLinks', () => ({
|
||||||
|
useConstructeurLinks: () => ({
|
||||||
|
fetchLinks: vi.fn().mockResolvedValue([]),
|
||||||
|
syncLinks: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/utils/printTemplates/machineReport', () => ({
|
||||||
|
buildMachinePrintContext: vi.fn(),
|
||||||
|
buildMachinePrintHtml: vi.fn().mockReturnValue('<html></html>'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/utils/documentPreview', () => ({
|
||||||
|
canPreviewDocument: vi.fn().mockReturnValue(false),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/documentDisplayUtils', () => ({
|
||||||
|
downloadDocument: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import under test (after mocks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useMachineDetailData } from '~/composables/useMachineDetailData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockGet.mockResolvedValue(mockStructureResponse)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadAndReturn(responseOverride?: unknown) {
|
||||||
|
if (responseOverride) {
|
||||||
|
mockGet.mockResolvedValue(responseOverride)
|
||||||
|
}
|
||||||
|
const result = useMachineDetailData(MACHINE_ID)
|
||||||
|
await result.loadMachineData()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 1. Hierarchy loading
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('hierarchy loading', () => {
|
||||||
|
it('loads machine with all core fields', async () => {
|
||||||
|
const { machine, machineName, machineReference, machineSiteId } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machine.value).not.toBeNull()
|
||||||
|
expect(machine.value!.id).toBe(MACHINE_ID)
|
||||||
|
expect(machine.value!.name).toBe('Presse hydraulique PH-200')
|
||||||
|
expect(machine.value!.reference).toBe('MACH-PH-200')
|
||||||
|
expect(machine.value!.prix).toBe(150000)
|
||||||
|
expect(machineName.value).toBe('Presse hydraulique PH-200')
|
||||||
|
expect(machineReference.value).toBe('MACH-PH-200')
|
||||||
|
expect(machineSiteId.value).toBe(SITE_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls GET /machines/{id}/structure', async () => {
|
||||||
|
await loadAndReturn()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(`/machines/${MACHINE_ID}/structure`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads componentLinks from structure response', async () => {
|
||||||
|
const { machineComponentLinks } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machineComponentLinks.value).toHaveLength(1)
|
||||||
|
expect(machineComponentLinks.value[0]!.id).toBe(COMPONENT_LINK_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds component hierarchy with composant data', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(components.value.length).toBeGreaterThanOrEqual(1)
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.composantId).toBe(COMPOSANT_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads piece links nested under their parent componentLink', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
const pieces = comp.pieces as Record<string, unknown>[]
|
||||||
|
expect(pieces).toBeDefined()
|
||||||
|
expect(pieces.length).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
const piece = pieces[0]!
|
||||||
|
expect(piece.pieceId).toBe(PIECE_ID)
|
||||||
|
expect(piece.parentComponentLinkId).toBe(COMPONENT_LINK_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves piece quantity', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
const pieces = comp.pieces as Record<string, unknown>[]
|
||||||
|
const piece = pieces[0]!
|
||||||
|
expect(piece.quantity).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads product links at machine level', async () => {
|
||||||
|
const { machineProductLinks } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machineProductLinks.value).toHaveLength(1)
|
||||||
|
expect(machineProductLinks.value[0]!.id).toBe(PRODUCT_LINK_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves machine documents', async () => {
|
||||||
|
const { machine } = await loadAndReturn()
|
||||||
|
|
||||||
|
const docs = machine.value!.documents as unknown[]
|
||||||
|
expect(docs).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves machine customFieldValues', async () => {
|
||||||
|
const { machine } = await loadAndReturn()
|
||||||
|
|
||||||
|
const cfv = machine.value!.customFieldValues as Record<string, unknown>[]
|
||||||
|
expect(cfv).toHaveLength(1)
|
||||||
|
expect((cfv[0] as any).value).toBe('SN-2025-PH200')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets loading to false after data load', async () => {
|
||||||
|
const { loading } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles failed API response gracefully', async () => {
|
||||||
|
const { machine, components, pieces } = await loadAndReturn({
|
||||||
|
success: false,
|
||||||
|
error: 'Not found',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(machine.value).toBeNull()
|
||||||
|
expect(components.value).toEqual([])
|
||||||
|
expect(pieces.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles invalid machine payload gracefully', async () => {
|
||||||
|
const { machine } = await loadAndReturn({
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(machine.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 2. Overrides
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('overrides on component links', () => {
|
||||||
|
it('uses nameOverride when present', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.name).toBe('Moteur principal PH-200')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to composant.name when nameOverride is null', async () => {
|
||||||
|
const { components } = await loadAndReturn(mockStructureNoOverrides)
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.name).toBe('Moteur principal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses referenceOverride when present', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.reference).toBe('COMP-MOT-PH200')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to composant.reference when referenceOverride is null', async () => {
|
||||||
|
const { components } = await loadAndReturn(mockStructureNoOverrides)
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.reference).toBe('COMP-MOT-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses prixOverride when present', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.prix).toBe(13000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to composant.prix when prixOverride is null', async () => {
|
||||||
|
const { components } = await loadAndReturn(mockStructureNoOverrides)
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.prix).toBe(12500)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('overrides on piece links', () => {
|
||||||
|
it('uses piece nameOverride when present', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
expect(piece.name).toBe('Roulement 6205-RS')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to piece.name when nameOverride is null', async () => {
|
||||||
|
const { components } = await loadAndReturn(mockStructureNoOverrides)
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
expect(piece.name).toBe('Roulement 6205')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves piece reference from underlying entity when no override', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
// The override only has name, so reference comes from the piece entity
|
||||||
|
expect(piece.reference).toBe('ROUL-6205')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves piece prix from underlying entity when no override', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
expect(piece.prix).toBe(45.90)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 3. Custom field values on links (context fields)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('contextCustomFieldValues on component links', () => {
|
||||||
|
it('loads contextCustomFieldValues on component hierarchy nodes', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
const ctxValues = comp.contextCustomFieldValues as Record<string, unknown>[]
|
||||||
|
expect(ctxValues).toBeDefined()
|
||||||
|
expect(ctxValues).toHaveLength(1)
|
||||||
|
expect((ctxValues[0] as any).value).toBe('Bloc moteur gauche')
|
||||||
|
expect((ctxValues[0] as any).customField.name).toBe('Position sur machine')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads contextCustomFields definitions on component hierarchy nodes', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
const ctxFields = comp.contextCustomFields as Record<string, unknown>[]
|
||||||
|
expect(ctxFields).toBeDefined()
|
||||||
|
expect(ctxFields).toHaveLength(1)
|
||||||
|
expect((ctxFields[0] as any).name).toBe('Position sur machine')
|
||||||
|
expect((ctxFields[0] as any).machineContextOnly).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('contextCustomFieldValues on piece links', () => {
|
||||||
|
it('loads contextCustomFieldValues on piece hierarchy nodes', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
const ctxValues = piece.contextCustomFieldValues as Record<string, unknown>[]
|
||||||
|
expect(ctxValues).toBeDefined()
|
||||||
|
expect(ctxValues).toHaveLength(1)
|
||||||
|
expect((ctxValues[0] as any).value).toBe('Cote entrainement')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has empty contextCustomFieldValues when none provided', async () => {
|
||||||
|
const { components } = await loadAndReturn(mockStructureNoOverrides)
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
const ctxValues = piece.contextCustomFieldValues as Record<string, unknown>[]
|
||||||
|
expect(ctxValues).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 4. Constructeur links on machine
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('constructeur links on machine', () => {
|
||||||
|
it('parses constructeur links from machine data', async () => {
|
||||||
|
const { constructeurLinks } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(constructeurLinks.value).toHaveLength(1)
|
||||||
|
expect(constructeurLinks.value[0]!.constructeurId).toBe(CONSTRUCTEUR_ID)
|
||||||
|
expect(constructeurLinks.value[0]!.supplierReference).toBe('SKF-PH200')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('populates machineConstructeurIds from links', async () => {
|
||||||
|
const { machineConstructeurIds } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machineConstructeurIds.value).toContain(CONSTRUCTEUR_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores original constructeur links for cancel rollback', async () => {
|
||||||
|
const { originalConstructeurLinks } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(originalConstructeurLinks.value).toHaveLength(1)
|
||||||
|
expect(originalConstructeurLinks.value[0]!.constructeurId).toBe(CONSTRUCTEUR_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hasMachineConstructeur is true when constructeur present', async () => {
|
||||||
|
const { hasMachineConstructeur } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(hasMachineConstructeur.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves constructeur display objects', async () => {
|
||||||
|
const { machineConstructeursDisplay } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machineConstructeursDisplay.value.length).toBeGreaterThanOrEqual(1)
|
||||||
|
const display = machineConstructeursDisplay.value[0] as any
|
||||||
|
expect(display.name).toBe('SKF')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 5. Site (required)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('site loaded with machine data', () => {
|
||||||
|
it('machineSiteId is populated from machine payload', async () => {
|
||||||
|
const { machineSiteId } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machineSiteId.value).toBe(SITE_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sites ref is available for dropdowns', async () => {
|
||||||
|
const { sites } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(sites.value).toHaveLength(1)
|
||||||
|
expect((sites.value[0] as any).name).toBe('Usine Nord')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('machine.site object is preserved in machine ref', async () => {
|
||||||
|
const { machine } = await loadAndReturn()
|
||||||
|
|
||||||
|
const site = machine.value!.site as Record<string, unknown>
|
||||||
|
expect(site.id).toBe(SITE_ID)
|
||||||
|
expect(site.name).toBe('Usine Nord')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 6. UI state defaults
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('UI state defaults', () => {
|
||||||
|
it('isEditMode starts as false', () => {
|
||||||
|
const { isEditMode } = useMachineDetailData(MACHINE_ID)
|
||||||
|
expect(isEditMode.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saving starts as false', () => {
|
||||||
|
const { saving } = useMachineDetailData(MACHINE_ID)
|
||||||
|
expect(saving.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loading starts as true', () => {
|
||||||
|
const { loading } = useMachineDetailData(MACHINE_ID)
|
||||||
|
expect(loading.value).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
644
frontend/tests/composables/usePieceEdit.test.ts
Normal file
644
frontend/tests/composables/usePieceEdit.test.ts
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
mockPieceFromApi,
|
||||||
|
mockLinkSKF,
|
||||||
|
mockLinkFAG,
|
||||||
|
mockConstructeurSKF,
|
||||||
|
mockConstructeurFAG,
|
||||||
|
wrapCollection,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — API layer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
const mockPostFormData = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: mockPostFormData,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — Toast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockShowSuccess = vi.fn()
|
||||||
|
const mockShowError = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: mockShowSuccess,
|
||||||
|
showError: mockShowError,
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePieces (updatePiece)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUpdatePiece = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieces', () => ({
|
||||||
|
usePieces: () => ({
|
||||||
|
updatePiece: mockUpdatePiece,
|
||||||
|
pieces: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePieceTypes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockPieceTypes = { value: [] as any[] }
|
||||||
|
const mockLoadPieceTypes = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieceTypes', () => ({
|
||||||
|
usePieceTypes: () => ({
|
||||||
|
pieceTypes: mockPieceTypes,
|
||||||
|
loadPieceTypes: mockLoadPieceTypes,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useDocuments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useDocuments', () => ({
|
||||||
|
useDocuments: () => ({
|
||||||
|
loadDocumentsByPiece: vi.fn().mockResolvedValue({ success: true, data: [] }),
|
||||||
|
uploadDocuments: vi.fn().mockResolvedValue({ success: true, data: [] }),
|
||||||
|
deleteDocument: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
documents: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurLinks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockFetchLinks = vi.fn().mockResolvedValue([])
|
||||||
|
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurLinks', () => ({
|
||||||
|
useConstructeurLinks: () => ({
|
||||||
|
fetchLinks: mockFetchLinks,
|
||||||
|
syncLinks: mockSyncLinks,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useCustomFieldInputs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockSaveAll = vi.fn().mockResolvedValue([])
|
||||||
|
const mockRefreshCF = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFieldInputs', () => ({
|
||||||
|
useCustomFieldInputs: () => ({
|
||||||
|
fields: { value: [] },
|
||||||
|
requiredFilled: { value: true },
|
||||||
|
saveAll: mockSaveAll,
|
||||||
|
refresh: mockRefreshCF,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePermissions (auto-imported in Nuxt)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.stubGlobal('usePermissions', () => ({
|
||||||
|
canEdit: { value: true },
|
||||||
|
canManage: { value: true },
|
||||||
|
isAdmin: { value: false },
|
||||||
|
isGranted: () => true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useEntityHistory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useEntityHistory', () => ({
|
||||||
|
useEntityHistory: () => ({
|
||||||
|
history: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
error: { value: null },
|
||||||
|
loadHistory: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — shared utils
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/shared/modelUtils', () => ({
|
||||||
|
formatPieceStructurePreview: () => '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/constructeurUtils', () => ({
|
||||||
|
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
|
||||||
|
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/utils/documentPreview', () => ({
|
||||||
|
canPreviewDocument: () => false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/services/modelTypes', () => ({
|
||||||
|
getModelType: vi.fn().mockResolvedValue(null),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/apiRelations', () => ({
|
||||||
|
extractRelationId: (rel: any) => {
|
||||||
|
if (typeof rel === 'string') return rel
|
||||||
|
if (rel && typeof rel === 'object' && 'id' in rel) return rel.id
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import under test (AFTER all vi.mock calls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { usePieceEdit } from '~/composables/usePieceEdit'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PIECE_ID = 'piece-001'
|
||||||
|
|
||||||
|
const mockPieceType = {
|
||||||
|
id: 'tp-bearing-001',
|
||||||
|
name: 'Roulement',
|
||||||
|
code: 'ROUL',
|
||||||
|
category: 'PIECE',
|
||||||
|
structure: {
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
typeProductId: 'tprod-grease-001',
|
||||||
|
typeProductLabel: 'Graisse SKF',
|
||||||
|
familyCode: 'LUB',
|
||||||
|
role: 'lubrification',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPieceWithProducts() {
|
||||||
|
return {
|
||||||
|
...mockPieceFromApi,
|
||||||
|
id: PIECE_ID,
|
||||||
|
'@id': `/api/pieces/${PIECE_ID}`,
|
||||||
|
description: 'Roulement haute performance',
|
||||||
|
prix: '42.50',
|
||||||
|
typePieceId: 'tp-bearing-001',
|
||||||
|
productIds: ['prod-001'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const tick = () => new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
async function createAndHydrate(overrides?: Record<string, any>) {
|
||||||
|
const pieceData = { ...buildPieceWithProducts(), ...overrides }
|
||||||
|
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url.includes(`/pieces/${PIECE_ID}`)) {
|
||||||
|
return Promise.resolve({ success: true, data: structuredClone(pieceData) })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true, data: wrapCollection([]) })
|
||||||
|
})
|
||||||
|
|
||||||
|
mockFetchLinks.mockResolvedValue([
|
||||||
|
{ ...mockLinkSKF },
|
||||||
|
{ ...mockLinkFAG },
|
||||||
|
])
|
||||||
|
|
||||||
|
const composable = usePieceEdit(PIECE_ID)
|
||||||
|
|
||||||
|
await composable.fetchPiece()
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
return composable
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// beforeEach
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPieceTypes.value = [mockPieceType]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// fetchPiece — hydration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('fetchPiece — hydration', () => {
|
||||||
|
it('loads all simple fields (name, reference, description, prix)', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(composable.editionForm.name).toBe('Roulement 6205')
|
||||||
|
expect(composable.editionForm.reference).toBe('ROUL-6205')
|
||||||
|
expect(composable.editionForm.description).toBe('Roulement haute performance')
|
||||||
|
expect(composable.editionForm.prix).toBe('42.50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads piece with product slots', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(composable.piece.value).not.toBeNull()
|
||||||
|
expect(composable.piece.value.productSlots).toHaveLength(1)
|
||||||
|
expect(composable.piece.value.productSlots[0].product.id).toBe('prod-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads constructeur links via fetchLinks', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(mockFetchLinks).toHaveBeenCalledWith('piece', PIECE_ID)
|
||||||
|
expect(composable.constructeurLinks.value).toHaveLength(2)
|
||||||
|
expect(composable.constructeurLinks.value[0].constructeurId).toBe(mockConstructeurSKF.id)
|
||||||
|
expect(composable.constructeurLinks.value[1].constructeurId).toBe(mockConstructeurFAG.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Product selections
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('product selections', () => {
|
||||||
|
it('setProductSelection updates the correct index', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// The structure has 1 product requirement, so productSelections should have 1 entry
|
||||||
|
composable.setProductSelection(0, 'prod-new-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
expect(composable.productSelections.value[0]).toBe('prod-new-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setProductSelection to null does not crash', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Set then clear
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
composable.setProductSelection(0, null)
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
expect(composable.productSelections.value[0]).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// submitEdition — no data loss
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitEdition — no data loss', () => {
|
||||||
|
it('sends all form fields in update payload', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.editionForm.name = 'Roulement modifie'
|
||||||
|
composable.editionForm.description = 'Nouvelle description'
|
||||||
|
composable.editionForm.reference = 'REF-MOD-001'
|
||||||
|
composable.editionForm.prix = '99.99'
|
||||||
|
|
||||||
|
// Ensure product selection is filled so submit proceeds
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
name: 'Roulement modifie',
|
||||||
|
description: 'Nouvelle description',
|
||||||
|
reference: 'REF-MOD-001',
|
||||||
|
prix: '99.99',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves custom fields after piece update', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs constructeur links', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
const [entityType, entityId, origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
||||||
|
expect(entityType).toBe('piece')
|
||||||
|
expect(entityId).toBe(PIECE_ID)
|
||||||
|
expect(origLinks).toHaveLength(2)
|
||||||
|
expect(formLinks).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editing name does not lose constructeur links', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Only edit name
|
||||||
|
composable.editionForm.name = 'Nouveau nom piece'
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.name).toBe('Nouveau nom piece')
|
||||||
|
|
||||||
|
// syncLinks still called with constructeur links preserved
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
||||||
|
expect(origLinks).toHaveLength(2)
|
||||||
|
expect(formLinks).toHaveLength(2)
|
||||||
|
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
|
||||||
|
expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editing name does not lose product slots', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Set product selection
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Now edit only name
|
||||||
|
composable.editionForm.name = 'Autre nom'
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.name).toBe('Autre nom')
|
||||||
|
// productIds should still contain the selection
|
||||||
|
expect(payload.productIds).toContain('prod-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adding a constructeur preserves existing ones', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Initially has SKF + FAG from fetchLinks
|
||||||
|
expect(composable.constructeurLinks.value).toHaveLength(2)
|
||||||
|
|
||||||
|
// Add a third constructeur
|
||||||
|
const newLink = {
|
||||||
|
linkId: null as string | null,
|
||||||
|
constructeurId: 'cstr-new-003',
|
||||||
|
constructeur: { id: 'cstr-new-003', name: 'NEW Corp', email: null, phone: null },
|
||||||
|
supplierReference: 'NEW-REF-001',
|
||||||
|
}
|
||||||
|
composable.constructeurLinks.value = [
|
||||||
|
...composable.constructeurLinks.value,
|
||||||
|
newLink,
|
||||||
|
]
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
||||||
|
// Original had 2 (SKF + FAG)
|
||||||
|
expect(origLinks).toHaveLength(2)
|
||||||
|
// Form now has 3 (SKF + FAG + NEW)
|
||||||
|
expect(formLinks).toHaveLength(3)
|
||||||
|
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
|
||||||
|
expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id)
|
||||||
|
expect(formLinks[2].constructeurId).toBe('cstr-new-003')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends both productId and productIds in payload', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.productId).toBe('prod-001')
|
||||||
|
expect(payload.productIds).toEqual(['prod-001'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('productId is the first product selection when multiple exist', async () => {
|
||||||
|
// Override the piece type to have 2 product requirements
|
||||||
|
const multiProductType = {
|
||||||
|
...mockPieceType,
|
||||||
|
structure: {
|
||||||
|
...mockPieceType.structure,
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
typeProductId: 'tprod-grease-001',
|
||||||
|
typeProductLabel: 'Graisse SKF',
|
||||||
|
familyCode: 'LUB',
|
||||||
|
role: 'lubrification',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeProductId: 'tprod-oil-002',
|
||||||
|
typeProductLabel: 'Huile',
|
||||||
|
familyCode: 'LUB',
|
||||||
|
role: 'lubrification secondaire',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockPieceTypes.value = [multiProductType]
|
||||||
|
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate({
|
||||||
|
productIds: ['prod-001', 'prod-002'],
|
||||||
|
})
|
||||||
|
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
composable.setProductSelection(1, 'prod-002')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.productId).toBe('prod-001')
|
||||||
|
expect(payload.productIds).toEqual(['prod-001', 'prod-002'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// submitEdition — null field handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitEdition — null field handling', () => {
|
||||||
|
it('empty prix sends null', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.editionForm.prix = ''
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('whitespace-only prix sends null', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.editionForm.prix = ' '
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty reference sends null', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.editionForm.reference = ''
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.reference).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valid prix is sent as string number', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.editionForm.prix = '99.50'
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBe('99.5')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// submitEdition — error paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitEdition — error paths', () => {
|
||||||
|
it('does not save custom fields when updatePiece fails', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: false, error: 'Server error' })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not save custom fields when updatePiece throws', async () => {
|
||||||
|
mockUpdatePiece.mockRejectedValue(new Error('Network failure'))
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith('Network failure')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when product selection is not filled', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Clear product selection
|
||||||
|
composable.setProductSelection(0, null)
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).not.toHaveBeenCalled()
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith('Sélectionnez un produit conforme au squelette.')
|
||||||
|
})
|
||||||
|
})
|
||||||
166
frontend/tests/composables/usePieces.test.ts
Normal file
166
frontend/tests/composables/usePieces.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
import { mockPieceFromApi, wrapCollection } from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const { clearPiecesCache } = usePieces()
|
||||||
|
clearPiecesCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createPiece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('createPiece', () => {
|
||||||
|
it('sends all fields including prix in POST payload', async () => {
|
||||||
|
const created = { ...mockPieceFromApi, id: 'piece-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createPiece } = usePieces()
|
||||||
|
await createPiece({
|
||||||
|
name: 'Roulement 6205',
|
||||||
|
reference: 'ROUL-6205',
|
||||||
|
prix: 25.50,
|
||||||
|
typePieceId: 'tp-bearing-001',
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/pieces', expect.objectContaining({
|
||||||
|
name: 'Roulement 6205',
|
||||||
|
reference: 'ROUL-6205',
|
||||||
|
prix: 25.50,
|
||||||
|
typePiece: '/api/model_types/tp-bearing-001',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips constructeur fields from payload', async () => {
|
||||||
|
const created = { ...mockPieceFromApi, id: 'piece-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createPiece } = usePieces()
|
||||||
|
await createPiece({
|
||||||
|
name: 'Test Piece',
|
||||||
|
constructeurIds: ['cstr-skf-001'],
|
||||||
|
constructeurs: [{ id: 'cstr-skf-001', name: 'SKF' }] as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = mockPost.mock.calls[0]![1]
|
||||||
|
expect(payload).not.toHaveProperty('constructeurIds')
|
||||||
|
expect(payload).not.toHaveProperty('constructeurs')
|
||||||
|
expect(payload).not.toHaveProperty('constructeurId')
|
||||||
|
expect(payload).not.toHaveProperty('constructeur')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds created piece to cache (pieces array and total)', async () => {
|
||||||
|
const created = { ...mockPieceFromApi, id: 'piece-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createPiece, pieces, total } = usePieces()
|
||||||
|
const result = await createPiece({ name: 'New Piece' })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(pieces.value).toHaveLength(1)
|
||||||
|
expect(pieces.value[0]!.id).toBe('piece-new')
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// updatePiece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('updatePiece', () => {
|
||||||
|
it('patches with supplied fields and updates cache', async () => {
|
||||||
|
// Seed cache first
|
||||||
|
const original = { ...mockPieceFromApi }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: original })
|
||||||
|
const { createPiece, updatePiece, pieces } = usePieces()
|
||||||
|
await createPiece({ name: 'Roulement 6205' })
|
||||||
|
|
||||||
|
const updated = { ...mockPieceFromApi, name: 'Updated Name', reference: 'ROUL-NEW' }
|
||||||
|
mockPatch.mockResolvedValue({ success: true, data: updated })
|
||||||
|
|
||||||
|
const result = await updatePiece(mockPieceFromApi.id, {
|
||||||
|
name: 'Updated Name',
|
||||||
|
reference: 'ROUL-NEW',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(`/pieces/${mockPieceFromApi.id}`, expect.objectContaining({
|
||||||
|
name: 'Updated Name',
|
||||||
|
reference: 'ROUL-NEW',
|
||||||
|
}))
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(pieces.value.find(p => p.id === mockPieceFromApi.id)?.name).toBe('Updated Name')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deletePiece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('deletePiece', () => {
|
||||||
|
it('removes piece from cache on success', async () => {
|
||||||
|
// Seed cache
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: { ...mockPieceFromApi } })
|
||||||
|
const { createPiece, deletePiece, pieces, total } = usePieces()
|
||||||
|
await createPiece({ name: 'To Delete' })
|
||||||
|
expect(pieces.value).toHaveLength(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
const result = await deletePiece(mockPieceFromApi.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(pieces.value).toHaveLength(0)
|
||||||
|
expect(total.value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not remove on failure', async () => {
|
||||||
|
// Seed cache
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: { ...mockPieceFromApi } })
|
||||||
|
const { createPiece, deletePiece, pieces, total } = usePieces()
|
||||||
|
await createPiece({ name: 'Should Stay' })
|
||||||
|
expect(pieces.value).toHaveLength(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
|
||||||
|
const result = await deletePiece(mockPieceFromApi.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(pieces.value).toHaveLength(1)
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
209
frontend/tests/composables/useProducts.test.ts
Normal file
209
frontend/tests/composables/useProducts.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
import { mockProductFromApi, mockConstructeurSKF, wrapCollection } from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const { clearProductsCache } = useProducts()
|
||||||
|
clearProductsCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createProduct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('createProduct', () => {
|
||||||
|
it('sends all fields including supplierPrice in POST payload', async () => {
|
||||||
|
const created = { ...mockProductFromApi, id: 'prod-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createProduct } = useProducts()
|
||||||
|
await createProduct({
|
||||||
|
name: 'Graisse LGMT2',
|
||||||
|
reference: 'LUB-LGMT2',
|
||||||
|
supplierPrice: 45.90,
|
||||||
|
typeProductId: 'tprod-grease-001',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/products', expect.objectContaining({
|
||||||
|
name: 'Graisse LGMT2',
|
||||||
|
reference: 'LUB-LGMT2',
|
||||||
|
supplierPrice: 45.90,
|
||||||
|
typeProduct: '/api/model_types/tprod-grease-001',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips constructeur fields from payload', async () => {
|
||||||
|
const created = { ...mockProductFromApi, id: 'prod-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createProduct } = useProducts()
|
||||||
|
await createProduct({
|
||||||
|
name: 'Test Product',
|
||||||
|
constructeurIds: ['cstr-skf-001'],
|
||||||
|
constructeurs: [mockConstructeurSKF] as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = mockPost.mock.calls[0]![1]
|
||||||
|
expect(payload).not.toHaveProperty('constructeurIds')
|
||||||
|
expect(payload).not.toHaveProperty('constructeurs')
|
||||||
|
expect(payload).not.toHaveProperty('constructeurId')
|
||||||
|
expect(payload).not.toHaveProperty('constructeur')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds created product to cache (products array and total)', async () => {
|
||||||
|
const created = { ...mockProductFromApi, id: 'prod-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createProduct, products, total } = useProducts()
|
||||||
|
const result = await createProduct({ name: 'New Product' })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(products.value).toHaveLength(1)
|
||||||
|
expect(products.value[0]!.id).toBe('prod-new')
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// updateProduct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('updateProduct', () => {
|
||||||
|
it('patches with supplied fields and updates cache', async () => {
|
||||||
|
// Seed cache first
|
||||||
|
const original = { ...mockProductFromApi }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: original })
|
||||||
|
const { createProduct, updateProduct, products } = useProducts()
|
||||||
|
await createProduct({ name: 'Graisse LGMT2' })
|
||||||
|
|
||||||
|
const updated = { ...mockProductFromApi, name: 'Updated Name', supplierPrice: 99.99 }
|
||||||
|
mockPatch.mockResolvedValue({ success: true, data: updated })
|
||||||
|
|
||||||
|
const result = await updateProduct(mockProductFromApi.id, {
|
||||||
|
name: 'Updated Name',
|
||||||
|
supplierPrice: 99.99,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(`/products/${mockProductFromApi.id}`, expect.objectContaining({
|
||||||
|
name: 'Updated Name',
|
||||||
|
supplierPrice: 99.99,
|
||||||
|
}))
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(products.value.find(p => p.id === mockProductFromApi.id)?.name).toBe('Updated Name')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteProduct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('deleteProduct', () => {
|
||||||
|
it('removes product from cache on success', async () => {
|
||||||
|
// Seed cache
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: { ...mockProductFromApi } })
|
||||||
|
const { createProduct, deleteProduct, products, total } = useProducts()
|
||||||
|
await createProduct({ name: 'To Delete' })
|
||||||
|
expect(products.value).toHaveLength(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
const result = await deleteProduct(mockProductFromApi.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(products.value).toHaveLength(0)
|
||||||
|
expect(total.value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not remove on failure', async () => {
|
||||||
|
// Seed cache
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: { ...mockProductFromApi } })
|
||||||
|
const { createProduct, deleteProduct, products, total } = useProducts()
|
||||||
|
await createProduct({ name: 'Should Stay' })
|
||||||
|
expect(products.value).toHaveLength(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
|
||||||
|
const result = await deleteProduct(mockProductFromApi.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(products.value).toHaveLength(1)
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getProduct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('getProduct', () => {
|
||||||
|
it('returns cached product if available with constructeurs (no extra API call)', async () => {
|
||||||
|
// Seed cache with a product that has resolved constructeurs
|
||||||
|
const productWithConstructeurs = {
|
||||||
|
...mockProductFromApi,
|
||||||
|
constructeurs: [mockConstructeurSKF],
|
||||||
|
}
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: productWithConstructeurs })
|
||||||
|
const { createProduct, getProduct } = useProducts()
|
||||||
|
await createProduct({ name: 'Cached' })
|
||||||
|
|
||||||
|
mockGet.mockClear()
|
||||||
|
const result = await getProduct(mockProductFromApi.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.id).toBe(mockProductFromApi.id)
|
||||||
|
expect(mockGet).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches from API with force: true', async () => {
|
||||||
|
// Seed cache with a product that has resolved constructeurs
|
||||||
|
const productWithConstructeurs = {
|
||||||
|
...mockProductFromApi,
|
||||||
|
constructeurs: [mockConstructeurSKF],
|
||||||
|
}
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: productWithConstructeurs })
|
||||||
|
const { createProduct, getProduct } = useProducts()
|
||||||
|
await createProduct({ name: 'Cached' })
|
||||||
|
|
||||||
|
const freshData = { ...mockProductFromApi, name: 'Fresh from API' }
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: freshData })
|
||||||
|
|
||||||
|
const result = await getProduct(mockProductFromApi.id, { force: true })
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(`/products/${mockProductFromApi.id}`)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.name).toBe('Fresh from API')
|
||||||
|
})
|
||||||
|
})
|
||||||
438
frontend/tests/fixtures/mockData.ts
vendored
Normal file
438
frontend/tests/fixtures/mockData.ts
vendored
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared mock data for Inventory frontend test suite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { ConstructeurLinkEntry, ConstructeurSummary } from '~/shared/constructeurUtils'
|
||||||
|
import type { CustomFieldDefinition, CustomFieldValue } from '~/shared/utils/customFields'
|
||||||
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constructeurs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockConstructeurSKF: ConstructeurSummary = {
|
||||||
|
id: 'cstr-skf-001',
|
||||||
|
name: 'SKF',
|
||||||
|
email: 'contact@skf.com',
|
||||||
|
phone: '+33 1 23 45 67 89',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockConstructeurFAG: ConstructeurSummary = {
|
||||||
|
id: 'cstr-fag-002',
|
||||||
|
name: 'FAG',
|
||||||
|
email: 'info@fag.de',
|
||||||
|
phone: '+49 9721 91 0',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constructeur link entries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockLinkSKF: ConstructeurLinkEntry = {
|
||||||
|
linkId: 'link-skf-001',
|
||||||
|
constructeurId: mockConstructeurSKF.id,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: 'SKF-6205-2RS',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockLinkFAG: ConstructeurLinkEntry = {
|
||||||
|
linkId: 'link-fag-002',
|
||||||
|
constructeurId: mockConstructeurFAG.id,
|
||||||
|
constructeur: mockConstructeurFAG,
|
||||||
|
supplierReference: 'FAG-6205-C-2HRS',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom field definitions (6 types)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockCustomFieldDefs: CustomFieldDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'cf-def-001',
|
||||||
|
name: 'Tension nominale',
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: '220',
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cf-def-002',
|
||||||
|
name: 'Certifié CE',
|
||||||
|
type: 'boolean',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: 'false',
|
||||||
|
orderIndex: 1,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cf-def-003',
|
||||||
|
name: 'Indice de protection',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
options: ['IP54', 'IP55', 'IP65'],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 2,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cf-def-004',
|
||||||
|
name: 'Date de calibration',
|
||||||
|
type: 'date',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 3,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cf-def-005',
|
||||||
|
name: 'Remarques techniques',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 4,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cf-def-006',
|
||||||
|
name: 'Position sur machine',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 5,
|
||||||
|
machineContextOnly: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom field values (matching first 5 defs)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockCustomFieldValues: CustomFieldValue[] = [
|
||||||
|
{
|
||||||
|
id: 'cfv-001',
|
||||||
|
value: '220',
|
||||||
|
customField: mockCustomFieldDefs[0]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfv-002',
|
||||||
|
value: 'true',
|
||||||
|
customField: mockCustomFieldDefs[1]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfv-003',
|
||||||
|
value: 'IP65',
|
||||||
|
customField: mockCustomFieldDefs[2]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfv-004',
|
||||||
|
value: '2025-06-15',
|
||||||
|
customField: mockCustomFieldDefs[3]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfv-005',
|
||||||
|
value: 'Roulement renforcé pour environnement humide',
|
||||||
|
customField: mockCustomFieldDefs[4]!,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component ModelType structure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockComponentStructure: ComponentModelStructure = {
|
||||||
|
customFields: [
|
||||||
|
{ name: 'Tension nominale', type: 'number', required: true, defaultValue: '220', orderIndex: 0 },
|
||||||
|
{ name: 'Certifié CE', type: 'boolean', required: false, defaultValue: 'false', orderIndex: 1 },
|
||||||
|
{ name: 'Indice de protection', type: 'select', required: false, options: ['IP54', 'IP55', 'IP65'], orderIndex: 2 },
|
||||||
|
],
|
||||||
|
pieces: [
|
||||||
|
{
|
||||||
|
typePieceId: 'tp-bearing-001',
|
||||||
|
typePieceLabel: 'Roulement',
|
||||||
|
reference: 'REF-PIECE-001',
|
||||||
|
familyCode: 'ROUL',
|
||||||
|
role: 'support',
|
||||||
|
quantity: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typePieceId: 'tp-seal-002',
|
||||||
|
typePieceLabel: 'Joint',
|
||||||
|
reference: 'REF-PIECE-002',
|
||||||
|
familyCode: 'JOINT',
|
||||||
|
role: 'étanchéité',
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
typeProductId: 'tprod-grease-001',
|
||||||
|
typeProductLabel: 'Graisse SKF',
|
||||||
|
reference: 'REF-PROD-001',
|
||||||
|
familyCode: 'LUB',
|
||||||
|
role: 'lubrification',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subcomponents: [
|
||||||
|
{
|
||||||
|
typeComposantId: 'tc-sub-001',
|
||||||
|
typeComposantLabel: 'Sous-ensemble palier',
|
||||||
|
familyCode: 'PAL',
|
||||||
|
alias: 'Palier avant',
|
||||||
|
subcomponents: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full API response — Composant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockComponentFromApi = {
|
||||||
|
'@id': '/api/composants/comp-001',
|
||||||
|
'@type': 'Composant',
|
||||||
|
id: 'comp-001',
|
||||||
|
name: 'Moteur principal',
|
||||||
|
reference: 'COMP-MOT-001',
|
||||||
|
typeComposant: { id: 'tc-moteur', name: 'Moteur électrique', code: 'MOT' },
|
||||||
|
site: { id: 'site-001', name: 'Usine Nord' },
|
||||||
|
pieceSlots: [
|
||||||
|
{
|
||||||
|
id: 'ps-001',
|
||||||
|
piece: { id: 'piece-001', name: 'Roulement 6205', reference: 'ROUL-6205' },
|
||||||
|
typePiece: { id: 'tp-bearing-001', name: 'Roulement' },
|
||||||
|
role: 'support',
|
||||||
|
quantity: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ps-002',
|
||||||
|
piece: { id: 'piece-002', name: 'Joint torique', reference: 'JOINT-001' },
|
||||||
|
typePiece: { id: 'tp-seal-002', name: 'Joint' },
|
||||||
|
role: 'étanchéité',
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
productSlots: [
|
||||||
|
{
|
||||||
|
id: 'prs-001',
|
||||||
|
product: { id: 'prod-001', name: 'Graisse LGMT2', reference: 'LUB-LGMT2' },
|
||||||
|
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF' },
|
||||||
|
role: 'lubrification',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subcomponentSlots: [
|
||||||
|
{
|
||||||
|
id: 'scs-001',
|
||||||
|
subcomponent: { id: 'comp-sub-001', name: 'Palier avant', reference: 'PAL-AV-001' },
|
||||||
|
typeComposant: { id: 'tc-sub-001', name: 'Sous-ensemble palier' },
|
||||||
|
alias: 'Palier avant',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
constructeurLinks: [
|
||||||
|
{
|
||||||
|
id: mockLinkSKF.linkId,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: mockLinkSKF.supplierReference,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFieldValues: mockCustomFieldValues.map(cfv => ({
|
||||||
|
id: cfv.id,
|
||||||
|
value: cfv.value,
|
||||||
|
customField: {
|
||||||
|
id: cfv.customField.id,
|
||||||
|
name: cfv.customField.name,
|
||||||
|
type: cfv.customField.type,
|
||||||
|
required: cfv.customField.required,
|
||||||
|
options: cfv.customField.options,
|
||||||
|
defaultValue: cfv.customField.defaultValue,
|
||||||
|
orderIndex: cfv.customField.orderIndex,
|
||||||
|
machineContextOnly: cfv.customField.machineContextOnly,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
createdAt: '2025-01-15T10:00:00+00:00',
|
||||||
|
updatedAt: '2025-03-20T14:30:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full API response — Piece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockPieceFromApi = {
|
||||||
|
'@id': '/api/pieces/piece-001',
|
||||||
|
'@type': 'Piece',
|
||||||
|
id: 'piece-001',
|
||||||
|
name: 'Roulement 6205',
|
||||||
|
reference: 'ROUL-6205',
|
||||||
|
typePiece: { id: 'tp-bearing-001', name: 'Roulement', code: 'ROUL' },
|
||||||
|
site: { id: 'site-001', name: 'Usine Nord' },
|
||||||
|
productSlots: [
|
||||||
|
{
|
||||||
|
id: 'pps-001',
|
||||||
|
product: { id: 'prod-001', name: 'Graisse LGMT2', reference: 'LUB-LGMT2' },
|
||||||
|
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF' },
|
||||||
|
role: 'lubrification',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
constructeurLinks: [
|
||||||
|
{
|
||||||
|
id: mockLinkSKF.linkId,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: mockLinkSKF.supplierReference,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mockLinkFAG.linkId,
|
||||||
|
constructeur: mockConstructeurFAG,
|
||||||
|
supplierReference: mockLinkFAG.supplierReference,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFieldValues: [
|
||||||
|
{
|
||||||
|
id: 'cfv-piece-001',
|
||||||
|
value: '6205',
|
||||||
|
customField: {
|
||||||
|
id: 'cf-piece-def-001',
|
||||||
|
name: 'Référence interne',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: '2025-01-10T08:00:00+00:00',
|
||||||
|
updatedAt: '2025-03-18T11:00:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full API response — Product
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockProductFromApi = {
|
||||||
|
'@id': '/api/products/prod-001',
|
||||||
|
'@type': 'Product',
|
||||||
|
id: 'prod-001',
|
||||||
|
name: 'Graisse LGMT2',
|
||||||
|
reference: 'LUB-LGMT2',
|
||||||
|
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF', code: 'LUB' },
|
||||||
|
site: { id: 'site-001', name: 'Usine Nord' },
|
||||||
|
supplierPrice: 45.90,
|
||||||
|
constructeurLinks: [
|
||||||
|
{
|
||||||
|
id: mockLinkSKF.linkId,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: 'LGMT2/1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: '2025-02-01T09:00:00+00:00',
|
||||||
|
updatedAt: '2025-03-10T16:00:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON-LD collection wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function wrapCollection<T>(items: T[], total?: number) {
|
||||||
|
return {
|
||||||
|
'@context': '/api/contexts/Collection',
|
||||||
|
'@id': '/api/collection',
|
||||||
|
'@type': 'Collection',
|
||||||
|
'totalItems': total ?? items.length,
|
||||||
|
'member': items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Machine custom field definitions (5 types)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockMachineCustomFieldDefs: CustomFieldDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'mcf-def-001',
|
||||||
|
name: 'Numéro de série',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcf-def-002',
|
||||||
|
name: 'En service',
|
||||||
|
type: 'boolean',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: 'false',
|
||||||
|
orderIndex: 1,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcf-def-003',
|
||||||
|
name: 'Puissance (kW)',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 2,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcf-def-004',
|
||||||
|
name: 'Catégorie ATEX',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
options: ['Zone 0', 'Zone 1', 'Zone 2', 'Non classé'],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 3,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcf-def-005',
|
||||||
|
name: 'Date mise en service',
|
||||||
|
type: 'date',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 4,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Machine custom field values (matching defs, includes number '0' and boolean 'true')
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockMachineCustomFieldValues: CustomFieldValue[] = [
|
||||||
|
{
|
||||||
|
id: 'mcfv-001',
|
||||||
|
value: 'SN-2025-001234',
|
||||||
|
customField: mockMachineCustomFieldDefs[0]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcfv-002',
|
||||||
|
value: 'true',
|
||||||
|
customField: mockMachineCustomFieldDefs[1]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcfv-003',
|
||||||
|
value: '0',
|
||||||
|
customField: mockMachineCustomFieldDefs[2]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcfv-004',
|
||||||
|
value: 'Zone 1',
|
||||||
|
customField: mockMachineCustomFieldDefs[3]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcfv-005',
|
||||||
|
value: '2025-01-15',
|
||||||
|
customField: mockMachineCustomFieldDefs[4]!,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,508 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import {
|
|
||||||
toFieldString,
|
|
||||||
fieldKey,
|
|
||||||
resolveFieldName,
|
|
||||||
resolveFieldType,
|
|
||||||
resolveRequiredFlag,
|
|
||||||
resolveOptions,
|
|
||||||
resolveDefaultValue,
|
|
||||||
formatDefaultValue,
|
|
||||||
normalizeCustomField,
|
|
||||||
normalizeCustomFieldInputs,
|
|
||||||
extractStoredCustomFieldValue,
|
|
||||||
buildCustomFieldInputs,
|
|
||||||
requiredCustomFieldsFilled,
|
|
||||||
shouldPersistField,
|
|
||||||
formatValueForPersistence,
|
|
||||||
buildCustomFieldMetadata,
|
|
||||||
type CustomFieldInput,
|
|
||||||
} from '~/shared/utils/customFieldFormUtils'
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// toFieldString
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('toFieldString', () => {
|
|
||||||
it('returns empty string for null', () => {
|
|
||||||
expect(toFieldString(null)).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty string for undefined', () => {
|
|
||||||
expect(toFieldString(undefined)).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns string as-is', () => {
|
|
||||||
expect(toFieldString('hello')).toBe('hello')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('converts number to string', () => {
|
|
||||||
expect(toFieldString(42)).toBe('42')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('converts boolean to string', () => {
|
|
||||||
expect(toFieldString(true)).toBe('true')
|
|
||||||
expect(toFieldString(false)).toBe('false')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty string for objects', () => {
|
|
||||||
expect(toFieldString({ foo: 'bar' })).toBe('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// fieldKey
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('fieldKey', () => {
|
|
||||||
it('prefers customFieldValueId', () => {
|
|
||||||
const field = { customFieldValueId: 'cfv-1', id: 'id-1', name: 'field' } as CustomFieldInput
|
|
||||||
expect(fieldKey(field, 0)).toBe('cfv-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to id', () => {
|
|
||||||
const field = { customFieldValueId: null, id: 'id-1', name: 'field' } as CustomFieldInput
|
|
||||||
expect(fieldKey(field, 0)).toBe('id-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to name-index', () => {
|
|
||||||
const field = { customFieldValueId: null, id: null, name: 'weight' } as CustomFieldInput
|
|
||||||
expect(fieldKey(field, 3)).toBe('weight-3')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// resolveFieldName
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('resolveFieldName', () => {
|
|
||||||
it('resolves from name property', () => {
|
|
||||||
expect(resolveFieldName({ name: 'Poids' })).toBe('Poids')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to key', () => {
|
|
||||||
expect(resolveFieldName({ key: 'poids' })).toBe('poids')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to label', () => {
|
|
||||||
expect(resolveFieldName({ label: 'Poids' })).toBe('Poids')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty string for empty object', () => {
|
|
||||||
expect(resolveFieldName({})).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty string for null', () => {
|
|
||||||
expect(resolveFieldName(null)).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('trims whitespace', () => {
|
|
||||||
expect(resolveFieldName({ name: ' Poids ' })).toBe('Poids')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// resolveFieldType
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('resolveFieldType', () => {
|
|
||||||
it('resolves valid type', () => {
|
|
||||||
expect(resolveFieldType({ type: 'number' })).toBe('number')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves case-insensitive', () => {
|
|
||||||
expect(resolveFieldType({ type: 'SELECT' })).toBe('select')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to text for unknown type', () => {
|
|
||||||
expect(resolveFieldType({ type: 'blob' })).toBe('text')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves nested value.type', () => {
|
|
||||||
expect(resolveFieldType({ value: { type: 'date' } })).toBe('date')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns text for missing type', () => {
|
|
||||||
expect(resolveFieldType({})).toBe('text')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles all allowed types', () => {
|
|
||||||
for (const type of ['text', 'number', 'select', 'boolean', 'date']) {
|
|
||||||
expect(resolveFieldType({ type })).toBe(type)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// resolveRequiredFlag
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('resolveRequiredFlag', () => {
|
|
||||||
it('resolves boolean true', () => {
|
|
||||||
expect(resolveRequiredFlag({ required: true })).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves boolean false', () => {
|
|
||||||
expect(resolveRequiredFlag({ required: false })).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves nested value.required', () => {
|
|
||||||
expect(resolveRequiredFlag({ value: { required: true } })).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves string "true"', () => {
|
|
||||||
expect(resolveRequiredFlag({ value: { required: 'true' } })).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves string "1"', () => {
|
|
||||||
expect(resolveRequiredFlag({ value: { required: '1' } })).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('defaults to false', () => {
|
|
||||||
expect(resolveRequiredFlag({})).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// resolveOptions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('resolveOptions', () => {
|
|
||||||
it('resolves array of strings', () => {
|
|
||||||
expect(resolveOptions({ options: ['A', 'B'] })).toEqual(['A', 'B'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves array of objects with value key', () => {
|
|
||||||
expect(resolveOptions({ options: [{ value: 'A' }, { value: 'B' }] })).toEqual(['A', 'B'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves array of objects with label key', () => {
|
|
||||||
expect(resolveOptions({ options: [{ label: 'Foo' }] })).toEqual(['Foo'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to value.options', () => {
|
|
||||||
expect(resolveOptions({ value: { options: ['X'] } })).toEqual(['X'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to value.choices', () => {
|
|
||||||
expect(resolveOptions({ value: { choices: ['Y'] } })).toEqual(['Y'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty array for no options', () => {
|
|
||||||
expect(resolveOptions({})).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters out empty strings', () => {
|
|
||||||
expect(resolveOptions({ options: ['A', '', 'B'] })).toEqual(['A', 'B'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters out null values', () => {
|
|
||||||
expect(resolveOptions({ options: [null, 'A'] })).toEqual(['A'])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// resolveDefaultValue
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('resolveDefaultValue', () => {
|
|
||||||
it('returns null for null input', () => {
|
|
||||||
expect(resolveDefaultValue(null)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves defaultValue', () => {
|
|
||||||
expect(resolveDefaultValue({ defaultValue: 'hello' })).toBe('hello')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves value (non-object)', () => {
|
|
||||||
expect(resolveDefaultValue({ value: 42 })).toBe(42)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves nested value.defaultValue', () => {
|
|
||||||
expect(resolveDefaultValue({ value: { defaultValue: 'nested' } })).toBe('nested')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when nothing found', () => {
|
|
||||||
expect(resolveDefaultValue({})).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// formatDefaultValue
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('formatDefaultValue', () => {
|
|
||||||
it('returns empty string for null', () => {
|
|
||||||
expect(formatDefaultValue('text', null)).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('converts number to string', () => {
|
|
||||||
expect(formatDefaultValue('number', 42)).toBe('42')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles boolean type with true', () => {
|
|
||||||
expect(formatDefaultValue('boolean', 'true')).toBe('true')
|
|
||||||
expect(formatDefaultValue('boolean', true)).toBe('true')
|
|
||||||
expect(formatDefaultValue('boolean', '1')).toBe('true')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles boolean type with false', () => {
|
|
||||||
expect(formatDefaultValue('boolean', 'false')).toBe('false')
|
|
||||||
expect(formatDefaultValue('boolean', false)).toBe('false')
|
|
||||||
expect(formatDefaultValue('boolean', '0')).toBe('false')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('unwraps nested defaultValue object', () => {
|
|
||||||
expect(formatDefaultValue('text', { defaultValue: 'inner' })).toBe('inner')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// normalizeCustomField
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('normalizeCustomField', () => {
|
|
||||||
it('normalizes a complete field', () => {
|
|
||||||
const result = normalizeCustomField({
|
|
||||||
id: 'cf-1',
|
|
||||||
name: 'Weight',
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
options: [],
|
|
||||||
orderIndex: 2,
|
|
||||||
})
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 'cf-1',
|
|
||||||
name: 'Weight',
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
options: [],
|
|
||||||
value: '',
|
|
||||||
customFieldId: 'cf-1',
|
|
||||||
customFieldValueId: null,
|
|
||||||
orderIndex: 2,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null for null input', () => {
|
|
||||||
expect(normalizeCustomField(null)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null for field without name', () => {
|
|
||||||
expect(normalizeCustomField({ type: 'text' })).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses fallback index', () => {
|
|
||||||
const result = normalizeCustomField({ name: 'Test' }, 5)
|
|
||||||
expect(result?.orderIndex).toBe(5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('defaults type to text', () => {
|
|
||||||
const result = normalizeCustomField({ name: 'Field' })
|
|
||||||
expect(result?.type).toBe('text')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// normalizeCustomFieldInputs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('normalizeCustomFieldInputs', () => {
|
|
||||||
it('returns empty array for null structure', () => {
|
|
||||||
expect(normalizeCustomFieldInputs(null)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty array for structure without customFields', () => {
|
|
||||||
expect(normalizeCustomFieldInputs({})).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('normalizes and sorts fields by orderIndex', () => {
|
|
||||||
const result = normalizeCustomFieldInputs({
|
|
||||||
customFields: [
|
|
||||||
{ name: 'B', orderIndex: 2 },
|
|
||||||
{ name: 'A', orderIndex: 1 },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0].name).toBe('A')
|
|
||||||
expect(result[1].name).toBe('B')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters out invalid fields', () => {
|
|
||||||
const result = normalizeCustomFieldInputs({
|
|
||||||
customFields: [{ name: 'Valid' }, null, { type: 'text' }],
|
|
||||||
})
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0].name).toBe('Valid')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// extractStoredCustomFieldValue
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('extractStoredCustomFieldValue', () => {
|
|
||||||
it('returns string directly', () => {
|
|
||||||
expect(extractStoredCustomFieldValue('hello')).toBe('hello')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns number directly', () => {
|
|
||||||
expect(extractStoredCustomFieldValue(42)).toBe(42)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty string for null', () => {
|
|
||||||
expect(extractStoredCustomFieldValue(null)).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('extracts .value from object', () => {
|
|
||||||
expect(extractStoredCustomFieldValue({ value: 'test' })).toBe('test')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('extracts nested .value.value', () => {
|
|
||||||
expect(extractStoredCustomFieldValue({ value: { value: 'deep' } })).toBe('deep')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('extracts customFieldValue.value', () => {
|
|
||||||
expect(extractStoredCustomFieldValue({ customFieldValue: { value: 'cfv' } })).toBe('cfv')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// buildCustomFieldInputs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('buildCustomFieldInputs', () => {
|
|
||||||
const definitions = {
|
|
||||||
customFields: [
|
|
||||||
{ name: 'Weight', type: 'number', required: true, id: 'cf-1', orderIndex: 0 },
|
|
||||||
{ name: 'Color', type: 'select', options: ['Red', 'Blue'], id: 'cf-2', orderIndex: 1 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
it('builds inputs from definitions without values', () => {
|
|
||||||
const result = buildCustomFieldInputs(definitions, null)
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0].name).toBe('Weight')
|
|
||||||
expect(result[0].value).toBe('')
|
|
||||||
expect(result[1].name).toBe('Color')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('merges stored values by id', () => {
|
|
||||||
const values = [
|
|
||||||
{ customField: { id: 'cf-1', name: 'Weight' }, id: 'cfv-1', value: '42' },
|
|
||||||
]
|
|
||||||
const result = buildCustomFieldInputs(definitions, values)
|
|
||||||
expect(result[0].value).toBe('42')
|
|
||||||
expect(result[0].customFieldValueId).toBe('cfv-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('merges stored values by name fallback', () => {
|
|
||||||
const values = [
|
|
||||||
{ name: 'Color', id: 'cfv-2', value: 'Blue' },
|
|
||||||
]
|
|
||||||
const result = buildCustomFieldInputs(definitions, values)
|
|
||||||
expect(result[1].value).toBe('Blue')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns empty array for null structure', () => {
|
|
||||||
expect(buildCustomFieldInputs(null, [])).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// requiredCustomFieldsFilled
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('requiredCustomFieldsFilled', () => {
|
|
||||||
it('returns true when all required fields are filled', () => {
|
|
||||||
const inputs: CustomFieldInput[] = [
|
|
||||||
{ id: null, name: 'A', type: 'text', required: true, options: [], value: 'hello', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
|
||||||
]
|
|
||||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false when required field is empty', () => {
|
|
||||||
const inputs: CustomFieldInput[] = [
|
|
||||||
{ id: null, name: 'A', type: 'text', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
|
||||||
]
|
|
||||||
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns true for non-required empty field', () => {
|
|
||||||
const inputs: CustomFieldInput[] = [
|
|
||||||
{ id: null, name: 'A', type: 'text', required: false, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
|
||||||
]
|
|
||||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats boolean "false" as filled', () => {
|
|
||||||
const inputs: CustomFieldInput[] = [
|
|
||||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'false', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
|
||||||
]
|
|
||||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats boolean "true" as filled', () => {
|
|
||||||
const inputs: CustomFieldInput[] = [
|
|
||||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'true', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
|
||||||
]
|
|
||||||
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats boolean empty as not filled', () => {
|
|
||||||
const inputs: CustomFieldInput[] = [
|
|
||||||
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
|
|
||||||
]
|
|
||||||
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns true for empty array', () => {
|
|
||||||
expect(requiredCustomFieldsFilled([])).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// shouldPersistField & formatValueForPersistence
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('shouldPersistField', () => {
|
|
||||||
it('returns true for non-empty text field', () => {
|
|
||||||
expect(shouldPersistField({ value: 'hello' } as CustomFieldInput)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false for empty text field', () => {
|
|
||||||
expect(shouldPersistField({ value: '', type: 'text' } as CustomFieldInput)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns true for boolean "true"', () => {
|
|
||||||
expect(shouldPersistField({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns true for boolean "false"', () => {
|
|
||||||
expect(shouldPersistField({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns false for boolean empty', () => {
|
|
||||||
expect(shouldPersistField({ value: '', type: 'boolean' } as CustomFieldInput)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('formatValueForPersistence', () => {
|
|
||||||
it('trims text value', () => {
|
|
||||||
expect(formatValueForPersistence({ value: ' hello ', type: 'text' } as CustomFieldInput)).toBe('hello')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns "true" for boolean true', () => {
|
|
||||||
expect(formatValueForPersistence({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe('true')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns "false" for boolean non-true', () => {
|
|
||||||
expect(formatValueForPersistence({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe('false')
|
|
||||||
expect(formatValueForPersistence({ value: '', type: 'boolean' } as CustomFieldInput)).toBe('false')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// buildCustomFieldMetadata
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('buildCustomFieldMetadata', () => {
|
|
||||||
it('builds metadata from field', () => {
|
|
||||||
const field: CustomFieldInput = {
|
|
||||||
id: null, name: 'Color', type: 'select', required: true,
|
|
||||||
options: ['Red', 'Blue'], value: 'Red',
|
|
||||||
customFieldId: null, customFieldValueId: null, orderIndex: 0,
|
|
||||||
}
|
|
||||||
expect(buildCustomFieldMetadata(field)).toEqual({
|
|
||||||
customFieldName: 'Color',
|
|
||||||
customFieldType: 'select',
|
|
||||||
customFieldRequired: true,
|
|
||||||
customFieldOptions: ['Red', 'Blue'],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user