Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e105fd070 | ||
|
|
a0c4597de0 | ||
|
|
d3f269452c | ||
|
|
b3fa927e77 | ||
|
|
f71f4c68da | ||
|
|
905d5c0957 | ||
|
|
03a5d05a2c | ||
|
|
069cc6e153 | ||
|
|
daa0cb1e28 | ||
|
|
b147845401 | ||
|
|
b67af56bd1 | ||
|
|
48c5c5bb33 | ||
| 1e2a1dae62 | |||
|
|
2a8042ba50 | ||
|
|
bc32648918 | ||
|
|
9027917ea2 | ||
|
|
5244698384 | ||
|
|
17ca857cc3 | ||
|
|
e6a85a9de4 | ||
|
|
a4ea44675a | ||
|
|
e5d0c690b7 | ||
|
|
0255d7dda1 | ||
|
|
dd7ab2b8e7 | ||
|
|
73c06169f3 | ||
|
|
5e8e7947f0 | ||
|
|
649f5a8570 | ||
| e6ba2259cb | |||
|
|
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 | ||
|
|
d6b74f01f9 | ||
|
|
5efedfabf8 |
@@ -4,7 +4,7 @@
|
||||
.env.test
|
||||
infra/dev/
|
||||
infra/prod/docker-compose.yml
|
||||
infra/prod/deploy.sh
|
||||
infra/prod/deploy.sh.example
|
||||
infra/prod/.env.example
|
||||
frontend/node_modules
|
||||
frontend/.nuxt
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
"X-Profile-Id": "admin-default-profile",
|
||||
"X-Profile-Password": "A123"
|
||||
}
|
||||
},
|
||||
"lesstime": {
|
||||
"type": "http",
|
||||
"url": "http://project.malio-dev.fr/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer b355c6cbf27d2a86d7eba1c3132c99bb3133f94cfd9e9243ffcc3c5ae1dc82c8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -81,6 +81,11 @@ make fixtures-reset # Reset DB + recharger fixtures
|
||||
make import-data # Importer les dumps SQL normalisés
|
||||
make cache-clear # Clear cache Symfony
|
||||
|
||||
# Import fournisseurs (customer.json → Constructeur + ConstructeurCategorie + ConstructeurTelephone)
|
||||
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run (par défaut)
|
||||
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
|
||||
# Non destructif : find-or-create par nom normalisé, ne change jamais un ID existant, n'ajoute que les téléphones/catégories manquants
|
||||
|
||||
# Release
|
||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
||||
```
|
||||
@@ -116,7 +121,9 @@ Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
## Architecture Backend
|
||||
|
||||
### Entités Principales
|
||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `ConstructeurCategorie`, `ConstructeurTelephone`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
||||
|
||||
> **Constructeur (Fournisseur)** : possède `name`, `email`, une collection `telephones` (1-N → `ConstructeurTelephone`, cascade/orphanRemoval) et `categories` (M2M → `ConstructeurCategorie`, table `constructeur_categories`). Sérialisation API Platform via les groupes `constructeur:read` / `constructeur:write` (téléphones & catégories embarqués). ⚠️ L'adder M2M s'appelle `addCategory()`/`removeCategory()` (l'inflector singularise `categories` → `category`), pas `addCategorie`. `ConstructeurCategorie` et `ConstructeurTelephone` sont aussi des `ApiResource` à part entière (`/api/constructeur_categories`, `/api/constructeur_telephones`).
|
||||
|
||||
#### Entités de normalisation (slots & skeleton requirements)
|
||||
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
|
||||
@@ -199,6 +206,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
|
||||
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
|
||||
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
|
||||
- **⚠️ Préfixe `/api`** : `useApi()` **prepend déjà** `apiBaseUrl` (= `/api` par défaut, cf. `nuxt.config.ts`). Les appels doivent donc utiliser des chemins **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` et **PAS** `api.get('/api/custom-fields/names')` (sinon 404 sur `/api/api/...`).
|
||||
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
|
||||
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
|
||||
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
|
||||
@@ -256,7 +264,7 @@ make test-setup # Créer/mettre à jour le schéma test
|
||||
### Pattern de test
|
||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
||||
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`, `createConstructeurCategorie()`, `createConstructeurTelephone()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
||||
|
||||
## URLs Locales
|
||||
@@ -264,3 +272,12 @@ make test-setup # Créer/mettre à jour le schéma test
|
||||
- Nuxt dev : `http://localhost:3001`
|
||||
- Adminer (PG) : `http://localhost:5050`
|
||||
- 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/framework-bundle": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0.2",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "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",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "2db01f705a09cf38007a2baa3b078e49",
|
||||
"content-hash": "5c54b1589d9e815f4c9b7e5e1d2d69c7",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -2437,6 +2437,109 @@
|
||||
},
|
||||
"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",
|
||||
"version": "2.6.0",
|
||||
@@ -5341,6 +5444,248 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v8.0.0",
|
||||
@@ -5567,6 +5912,93 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v1.33.0",
|
||||
|
||||
@@ -9,6 +9,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Symfony\AI\McpBundle\McpBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
|
||||
@@ -22,4 +23,5 @@ return [
|
||||
ApiPlatformBundle::class => ['all' => true],
|
||||
DAMADoctrineTestBundle::class => ['test' => 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
|
||||
limit: 5
|
||||
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'
|
||||
|
||||
@@ -69,3 +69,8 @@ when@test:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\SkeletonStructureService:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.12'
|
||||
app.version: '1.9.37'
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
@@ -0,0 +1,926 @@
|
||||
# Custom Field Name Autocomplete — 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:** Ajouter une autocomplétion sur les noms de champs personnalisés dans tous les éditeurs (machine + ModelType) pour permettre la réutilisation des noms existants tout en gardant la possibilité d'en créer de nouveaux.
|
||||
|
||||
**Architecture:**
|
||||
- **Backend** : un endpoint utilitaire `GET /api/custom-fields/names` qui retourne la liste plate des noms distincts de la table `custom_fields`.
|
||||
- **Frontend** : extension de `SearchSelect.vue` avec un prop `creatable`, composable `useCustomFieldNameSuggestions` avec cache module-level, composant wrapper `CustomFieldNameInput.vue`, migration de 4 éditeurs.
|
||||
|
||||
**Tech Stack:** Symfony 8 + API Platform + Doctrine DBAL, Nuxt 4 + Vue 3 Composition API + TypeScript, DaisyUI.
|
||||
|
||||
**Référence spec:** `docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend — Controller `CustomFieldNamesController`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Controller/CustomFieldNamesController.php`
|
||||
|
||||
- [ ] **Step 1: Créer le controller**
|
||||
|
||||
Créer `src/Controller/CustomFieldNamesController.php` avec le contenu :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[AsController]
|
||||
final class CustomFieldNamesController
|
||||
{
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route(
|
||||
path: '/api/custom-fields/names',
|
||||
name: 'api_custom_fields_names',
|
||||
methods: ['GET']
|
||||
)]
|
||||
#[IsGranted('ROLE_VIEWER')]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT name
|
||||
FROM custom_fields
|
||||
WHERE name IS NOT NULL AND name <> ''
|
||||
ORDER BY name ASC
|
||||
SQL;
|
||||
|
||||
$names = $this->connection->fetchFirstColumn($sql);
|
||||
|
||||
return new JsonResponse($names);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier que la route est bien exposée**
|
||||
|
||||
Exécuter :
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console debug:router | grep custom-fields/names
|
||||
```
|
||||
|
||||
Attendu : une ligne contenant `GET /api/custom-fields/names` et `api_custom_fields_names`.
|
||||
|
||||
- [ ] **Step 3: Tester manuellement le endpoint**
|
||||
|
||||
```bash
|
||||
curl -s -b "$(curl -s -c - -X POST http://localhost:8081/api/session/profile \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"admin","password":"admin"}' | grep PHPSESSID)" \
|
||||
http://localhost:8081/api/custom-fields/names | head -c 500
|
||||
```
|
||||
|
||||
> Si la session est galère à monter en curl, on peut tester via le navigateur après login (DevTools → fetch).
|
||||
|
||||
Attendu : un tableau JSON `["Numéro de série", "Tension", ...]` (ou `[]` si la base de dev est vide).
|
||||
|
||||
- [ ] **Step 4: Lancer php-cs-fixer**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/CustomFieldNamesController.php
|
||||
git commit -m "feat(custom-fields) : ajoute endpoint GET /api/custom-fields/names
|
||||
|
||||
Retourne la liste plate des noms de champs perso distincts (table
|
||||
custom_fields), pour alimenter une autocompletion cote frontend."
|
||||
```
|
||||
|
||||
> ⚠️ Le pre-commit hook va lancer PHPUnit. Si des tests existants échouent (peu probable car on n'a touché à rien d'existant), résoudre le souci avant de continuer.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Backend — Test PHPUnit du endpoint
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Api/Controller/CustomFieldNamesControllerTest.php`
|
||||
|
||||
- [ ] **Step 1: Repérer un test existant à copier-coller pour le style**
|
||||
|
||||
Lire `tests/Api/Controller/HealthCheckController*Test.php` ou un controller simple existant pour récupérer le pattern (auth helpers, `ApiTestCase`). Adapter selon ce qu'on trouve.
|
||||
|
||||
```bash
|
||||
ls tests/Api/Controller/ | head
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Créer le test**
|
||||
|
||||
Créer `tests/Api/Controller/CustomFieldNamesControllerTest.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Controller;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
final class CustomFieldNamesControllerTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testReturns401WhenUnauthenticated(): void
|
||||
{
|
||||
$client = $this->createUnauthenticatedClient();
|
||||
$client->request('GET', '/api/custom-fields/names');
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testReturnsEmptyArrayWhenNoCustomFields(): void
|
||||
{
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', '/api/custom-fields/names');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertIsArray($data);
|
||||
}
|
||||
|
||||
public function testReturnsDistinctSortedNames(): void
|
||||
{
|
||||
// Crée 3 machines avec des CustomField : "Tension", "Numéro de série", "Tension" (doublon)
|
||||
$machine1 = $this->createMachine();
|
||||
$this->createCustomField(['name' => 'Tension', 'machine' => $machine1]);
|
||||
$this->createCustomField(['name' => 'Numéro de série', 'machine' => $machine1]);
|
||||
|
||||
$machine2 = $this->createMachine();
|
||||
$this->createCustomField(['name' => 'Tension', 'machine' => $machine2]); // doublon
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('GET', '/api/custom-fields/names');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
self::assertContains('Tension', $data);
|
||||
self::assertContains('Numéro de série', $data);
|
||||
// Pas de doublon
|
||||
self::assertSame(count(array_unique($data)), count($data));
|
||||
// Tri alpha
|
||||
$sorted = $data;
|
||||
sort($sorted, SORT_STRING);
|
||||
self::assertSame($sorted, $data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Si la factory `createCustomField` n'a pas la signature attendue (1er argument = array), regarder `tests/AbstractApiTestCase.php` pour adapter aux helpers réels du projet.
|
||||
|
||||
- [ ] **Step 3: Vérifier que les helpers utilisés existent**
|
||||
|
||||
```bash
|
||||
grep -n "createCustomField\|createMachine\|createViewerClient\|createUnauthenticatedClient" tests/AbstractApiTestCase.php | head
|
||||
```
|
||||
|
||||
Si l'un des helpers manque ou a une autre signature, **adapter le test** plutôt que d'ajouter de nouveaux helpers.
|
||||
|
||||
- [ ] **Step 4: Lancer le test ciblé**
|
||||
|
||||
```bash
|
||||
make test FILES=tests/Api/Controller/CustomFieldNamesControllerTest.php
|
||||
```
|
||||
|
||||
Attendu : 3 tests OK.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Api/Controller/CustomFieldNamesControllerTest.php
|
||||
git commit -m "test(custom-fields) : ajoute test PHPUnit pour endpoint /api/custom-fields/names"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Frontend — Étendre `SearchSelect.vue` avec le prop `creatable`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/common/SearchSelect.vue`
|
||||
|
||||
- [ ] **Step 1: Ajouter le prop `creatable` au composant**
|
||||
|
||||
Dans le bloc `defineProps` (lignes ~91-141), ajouter après le prop `serverSearch` :
|
||||
|
||||
```js
|
||||
creatable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Modifier `handleInput` pour emit en mode creatable**
|
||||
|
||||
Remplacer la fonction `handleInput` (lignes ~284-289) par :
|
||||
|
||||
```js
|
||||
function handleInput () {
|
||||
if (!openDropdown.value) {
|
||||
openDropdown.value = true
|
||||
}
|
||||
if (props.creatable) {
|
||||
emit('update:modelValue', searchTerm.value)
|
||||
}
|
||||
emit('search', searchTerm.value)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Modifier `closeDropdown` pour ne pas reset en mode creatable**
|
||||
|
||||
Remplacer la fonction `closeDropdown` (lignes ~297-304) par :
|
||||
|
||||
```js
|
||||
function closeDropdown () {
|
||||
openDropdown.value = false
|
||||
if (props.creatable) {
|
||||
return // garde le texte tapé tel quel
|
||||
}
|
||||
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||
emit('update:modelValue', '')
|
||||
} else if (selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Ajouter une computed `creatableSuggestion`**
|
||||
|
||||
Dans le bloc `<script setup>`, après la `computed displayedOptions` (ligne ~173), ajouter :
|
||||
|
||||
```js
|
||||
const creatableSuggestion = computed(() => {
|
||||
if (!props.creatable) return null
|
||||
const term = searchTerm.value.trim()
|
||||
if (!term) return null
|
||||
// Affiche "Créer ..." uniquement si aucune option exacte ne matche (case-insensitive)
|
||||
const exists = baseOptions.value.some(option => {
|
||||
const label = resolveLabel(option).toLowerCase()
|
||||
return label === term.toLowerCase()
|
||||
})
|
||||
return exists ? null : term
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Afficher la ligne "Créer ..." dans le template**
|
||||
|
||||
Localiser le bloc dropdown (lignes ~40-81). Juste **après** la `<ul>` qui contient les options (donc juste avant la fermeture du `<div v-if="openDropdown">`), ajouter :
|
||||
|
||||
```vue
|
||||
<button
|
||||
v-if="creatableSuggestion"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
|
||||
@click="confirmCreatable"
|
||||
>
|
||||
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
|
||||
Créer « {{ creatableSuggestion }} »
|
||||
</button>
|
||||
```
|
||||
|
||||
Ajouter l'import en haut :
|
||||
```js
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Ajouter la fonction `confirmCreatable`**
|
||||
|
||||
Après `clearSelection` (ligne ~295), ajouter :
|
||||
|
||||
```js
|
||||
function confirmCreatable () {
|
||||
if (creatableSuggestion.value) {
|
||||
emit('update:modelValue', creatableSuggestion.value)
|
||||
}
|
||||
openDropdown.value = false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Ajuster la sync `searchTerm` ↔ `modelValue` en mode creatable**
|
||||
|
||||
Localiser le `watch` sur `modelValue` (lignes ~194-202). Remplacer par :
|
||||
|
||||
```js
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (props.creatable) {
|
||||
if (searchTerm.value !== props.modelValue) {
|
||||
searchTerm.value = String(props.modelValue ?? '')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!openDropdown.value) {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
```
|
||||
|
||||
> En mode creatable, `modelValue` et `searchTerm` reflètent la même chose (le texte tapé) — on évite juste la boucle infinie en testant l'égalité.
|
||||
|
||||
- [ ] **Step 8: Vérifier le typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
Attendu : 0 errors.
|
||||
|
||||
- [ ] **Step 9: Lancer ESLint**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint:fix
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Test de non-régression manuel**
|
||||
|
||||
Vérifier qu'un usage existant de `SearchSelect` (par exemple sur la page `frontend/app/pages/index.vue` ou similaire — chercher `<SearchSelect`) fonctionne toujours **sans** le prop `creatable` : le comportement strict doit être identique à avant.
|
||||
|
||||
```bash
|
||||
cd frontend && grep -rln "SearchSelect" app/ | head -5
|
||||
```
|
||||
|
||||
Ouvrir un de ces écrans en dev et vérifier que :
|
||||
- La sélection d'une option marche
|
||||
- Le blur sans sélection reset au label précédent (= mode strict inchangé)
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/components/common/SearchSelect.vue
|
||||
git commit -m "feat(search-select) : ajoute prop creatable pour autoriser la saisie libre
|
||||
|
||||
En mode creatable=true, le composant emit le texte tape en temps reel
|
||||
et ne reset plus au blur. Une ligne 'Creer XYZ' apparait quand le texte
|
||||
ne matche aucune option. Mode strict (defaut) inchange."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Frontend — Composable `useCustomFieldNameSuggestions`
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/app/composables/useCustomFieldNameSuggestions.ts`
|
||||
|
||||
- [ ] **Step 1: Vérifier le pattern `useApi` existant**
|
||||
|
||||
```bash
|
||||
cat frontend/app/composables/useApi.ts | head -30
|
||||
```
|
||||
|
||||
Noter la signature exacte (`<T>(path, opts?) => Promise<T>` ou autre) pour l'adapter.
|
||||
|
||||
- [ ] **Step 2: Créer le composable**
|
||||
|
||||
Créer `frontend/app/composables/useCustomFieldNameSuggestions.ts` :
|
||||
|
||||
```ts
|
||||
import { ref } from 'vue'
|
||||
|
||||
const cache = ref<string[] | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
interface Deps {
|
||||
api: ReturnType<typeof useApi>
|
||||
}
|
||||
|
||||
export function useCustomFieldNameSuggestions(deps: Deps) {
|
||||
const { api } = deps
|
||||
|
||||
async function load(force = false): Promise<string[]> {
|
||||
if (cache.value && !force) return cache.value
|
||||
if (loading.value) return cache.value ?? []
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await api<string[]>('/api/custom-fields/names')
|
||||
cache.value = Array.isArray(result) ? result : []
|
||||
return cache.value
|
||||
} catch (err) {
|
||||
console.error('[useCustomFieldNameSuggestions] failed to load', err)
|
||||
cache.value = cache.value ?? []
|
||||
return cache.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function invalidate(): void {
|
||||
cache.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: cache,
|
||||
loading,
|
||||
load,
|
||||
invalidate,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Note : `cache` et `loading` sont déclarés **au niveau du module** (hors de la fonction) → cache partagé entre toutes les instances.
|
||||
|
||||
- [ ] **Step 3: Vérifier le typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
Attendu : 0 errors. Si le `ReturnType<typeof useApi>` pose souci (selon comment `useApi` est typé), remplacer par un type explicite plus simple :
|
||||
|
||||
```ts
|
||||
interface Deps {
|
||||
api: <T>(path: string, opts?: RequestInit) => Promise<T>
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/composables/useCustomFieldNameSuggestions.ts
|
||||
git commit -m "feat(custom-fields) : ajoute composable useCustomFieldNameSuggestions
|
||||
|
||||
Cache module-level partage entre toutes les instances. Lazy load au
|
||||
premier appel a load(). invalidate() permet de forcer un refresh apres
|
||||
creation/modification d'un champ perso."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Frontend — Composant wrapper `CustomFieldNameInput`
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/app/components/common/CustomFieldNameInput.vue`
|
||||
|
||||
- [ ] **Step 1: Créer le composant**
|
||||
|
||||
Créer `frontend/app/components/common/CustomFieldNameInput.vue` :
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<SearchSelect
|
||||
:model-value="modelValue"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
option-value="name"
|
||||
option-label="name"
|
||||
creatable
|
||||
:size="size"
|
||||
@update:model-value="onUpdate"
|
||||
@focus="ensureLoaded"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import SearchSelect from './SearchSelect.vue'
|
||||
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
}>(), {
|
||||
placeholder: 'Nom du champ',
|
||||
size: 'xs',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { suggestions, load } = useCustomFieldNameSuggestions({ api: useApi() })
|
||||
|
||||
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
|
||||
|
||||
function ensureLoaded(): void {
|
||||
void load()
|
||||
}
|
||||
|
||||
function onUpdate(value: string | number): void {
|
||||
emit('update:modelValue', String(value ?? ''))
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
> `SearchSelect` n'expose pas nativement un événement `@focus`. Vérifier dans la Task 3 si on l'a ajouté ou si on doit charger autrement.
|
||||
|
||||
- [ ] **Step 2: Exposer `@focus` depuis `SearchSelect.vue`**
|
||||
|
||||
Retourner sur `SearchSelect.vue` et vérifier si `@focus` est propagé. Si non, modifier le handler `handleFocus` (ligne ~267-272) pour également émettre :
|
||||
|
||||
```js
|
||||
const emit = defineEmits(['update:modelValue', 'search', 'focus'])
|
||||
|
||||
function handleFocus () {
|
||||
openDropdown.value = true
|
||||
if (searchTerm.value === '' && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
emit('focus')
|
||||
}
|
||||
```
|
||||
|
||||
Ajouter `'focus'` à la liste des emits si pas déjà présent.
|
||||
|
||||
- [ ] **Step 3: Vérifier le typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vérifier l'auto-import Nuxt**
|
||||
|
||||
Le composant étant dans `components/common/`, Nuxt devrait l'auto-importer. Vérifier après build :
|
||||
|
||||
```bash
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
Sans erreur de référence `CustomFieldNameInput is not defined` quand on l'utilisera dans les tâches suivantes.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/components/common/CustomFieldNameInput.vue frontend/app/components/common/SearchSelect.vue
|
||||
git commit -m "feat(custom-fields) : ajoute CustomFieldNameInput wrapper
|
||||
|
||||
Encapsule SearchSelect en mode creatable, branche useCustomFieldName-
|
||||
Suggestions, charge la liste au focus. Permet de remplacer un simple
|
||||
<input v-model='field.name'> par <CustomFieldNameInput v-model='field.name'>
|
||||
dans les editeurs de champs perso."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Frontend — Migrer `MachineCustomFieldDefEditor.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
|
||||
|
||||
- [ ] **Step 1: Remplacer l'input du nom**
|
||||
|
||||
Dans `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`, localiser les lignes 36-41 :
|
||||
|
||||
```vue
|
||||
<input
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
```
|
||||
|
||||
Remplacer par :
|
||||
|
||||
```vue
|
||||
<CustomFieldNameInput
|
||||
v-model="field.name"
|
||||
placeholder="Nom du champ"
|
||||
size="sm"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier le typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test manuel rapide**
|
||||
|
||||
Ouvrir une machine en édition, ajouter un champ perso, vérifier que l'input :
|
||||
1. Affiche un dropdown au focus avec les noms existants
|
||||
2. Filtre quand on tape
|
||||
3. Sélection d'une suggestion → input rempli
|
||||
4. Texte libre + blur → garde le texte tapé
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/components/machine/MachineCustomFieldDefEditor.vue
|
||||
git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldDefEditor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend — Migrer `MachineCustomFieldsCard.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue`
|
||||
|
||||
- [ ] **Step 1: Remplacer l'input du nom**
|
||||
|
||||
Dans `frontend/app/components/machine/MachineCustomFieldsCard.vue`, localiser les lignes ~53-59 :
|
||||
|
||||
```vue
|
||||
<input
|
||||
:value="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
placeholder="Nom du champ"
|
||||
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
```
|
||||
|
||||
⚠️ **Attention** : cet input utilise `:value` + `@blur` (pas `v-model`) parce qu'il déclenche une mise à jour seulement au blur (avec un appel API).
|
||||
|
||||
Remplacer par :
|
||||
|
||||
```vue
|
||||
<CustomFieldNameInput
|
||||
:model-value="field.name"
|
||||
placeholder="Nom du champ"
|
||||
size="sm"
|
||||
@update:model-value="(value) => handleDefinitionUpdate(field, 'name', value)"
|
||||
/>
|
||||
```
|
||||
|
||||
> Le `@update:model-value` se déclenchera à chaque changement (donc à chaque caractère tapé en mode creatable). Si ce comportement génère trop d'appels API, on peut wrapper avec un `debounce` côté `handleDefinitionUpdate`. Pour l'instant, on garde simple.
|
||||
|
||||
- [ ] **Step 2: Vérifier le comportement de `handleDefinitionUpdate`**
|
||||
|
||||
Vérifier que cette fonction est idempotente (rejouer un même nom = pas d'effet). Cherche la fonction dans le composant et confirme qu'elle compare l'ancienne/nouvelle valeur avant d'appeler l'API.
|
||||
|
||||
```bash
|
||||
grep -n "handleDefinitionUpdate" frontend/app/components/machine/MachineCustomFieldsCard.vue
|
||||
```
|
||||
|
||||
Si elle ne dédoublonne pas, l'ajout d'un test rapide `if (field.name === value) return` peut éviter des PATCH inutiles.
|
||||
|
||||
- [ ] **Step 3: Vérifier le typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test manuel**
|
||||
|
||||
Sur la page d'une machine, modifier inline un nom de champ perso → vérifier que ça déclenche un PATCH unique (DevTools Network).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/components/machine/MachineCustomFieldsCard.vue
|
||||
git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldsCard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Frontend — Migrer `PieceModelStructureEditor.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/PieceModelStructureEditor.vue`
|
||||
|
||||
- [ ] **Step 1: Remplacer l'input du nom**
|
||||
|
||||
Dans `frontend/app/components/PieceModelStructureEditor.vue`, localiser les lignes 97-102 :
|
||||
|
||||
```vue
|
||||
<input
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
```
|
||||
|
||||
Remplacer par :
|
||||
|
||||
```vue
|
||||
<CustomFieldNameInput
|
||||
v-model="field.name"
|
||||
placeholder="Nom du champ"
|
||||
size="xs"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier le typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test manuel**
|
||||
|
||||
Ouvrir un ModelType (catégorie composant ou skeleton), ajouter une pièce dans le skeleton, lui ajouter un champ perso → vérifier dropdown.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/components/PieceModelStructureEditor.vue
|
||||
git commit -m "feat(custom-fields) : autocomplete sur le nom dans PieceModelStructureEditor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Frontend — Migrer `StructureNodeEditor.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/StructureNodeEditor.vue`
|
||||
|
||||
- [ ] **Step 1: Remplacer l'input du nom**
|
||||
|
||||
Dans `frontend/app/components/StructureNodeEditor.vue`, localiser les lignes 106-111 :
|
||||
|
||||
```vue
|
||||
<input
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
/>
|
||||
```
|
||||
|
||||
Remplacer par :
|
||||
|
||||
```vue
|
||||
<CustomFieldNameInput
|
||||
v-model="field.name"
|
||||
placeholder="Nom du champ"
|
||||
size="xs"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier le typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test manuel**
|
||||
|
||||
Ouvrir un ModelType de catégorie machine, naviguer dans la structure (composants/sous-composants), ajouter un champ perso à un node → vérifier dropdown.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/components/StructureNodeEditor.vue
|
||||
git commit -m "feat(custom-fields) : autocomplete sur le nom dans StructureNodeEditor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Frontend — Invalidation du cache après save
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/composables/useMachineCustomFieldDefs.ts`
|
||||
- Modify: `frontend/app/components/model-types/ModelTypeForm.vue`
|
||||
|
||||
- [ ] **Step 1: Repérer le save dans `useMachineCustomFieldDefs.ts`**
|
||||
|
||||
```bash
|
||||
grep -n "POST\|PATCH\|api(" frontend/app/composables/useMachineCustomFieldDefs.ts | head -20
|
||||
```
|
||||
|
||||
Localiser la(es) fonction(s) qui sauvegarde les champs perso (probablement `saveDefinitions`, `addCustomFields`, etc.).
|
||||
|
||||
- [ ] **Step 2: Ajouter l'invalidation après save**
|
||||
|
||||
Dans `useMachineCustomFieldDefs.ts`, en haut du fichier, après les imports existants :
|
||||
|
||||
```ts
|
||||
import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions'
|
||||
```
|
||||
|
||||
Dans le corps du composable, ajouter (à placer près des autres `use*` calls) :
|
||||
|
||||
```ts
|
||||
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() })
|
||||
```
|
||||
|
||||
Puis, après chaque save réussi (à la fin du `try` du POST/PATCH des definitions), appeler :
|
||||
|
||||
```ts
|
||||
invalidateCustomFieldNames()
|
||||
```
|
||||
|
||||
> Identifier précisément les points de save dans le fichier — probablement 1 ou 2 endroits maximum.
|
||||
|
||||
- [ ] **Step 3: Repérer le save dans `ModelTypeForm.vue`**
|
||||
|
||||
```bash
|
||||
grep -n "POST\|PATCH\|api(\|emit('saved')\|emit('save'" frontend/app/components/model-types/ModelTypeForm.vue | head -20
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Ajouter l'invalidation après save**
|
||||
|
||||
Dans le `<script setup>` de `ModelTypeForm.vue` :
|
||||
|
||||
```ts
|
||||
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
|
||||
|
||||
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() })
|
||||
```
|
||||
|
||||
Puis, après la sauvegarde réussie du ModelType (typiquement après le `await api(...)` qui POST/PATCH `/api/model_types/...`) :
|
||||
|
||||
```ts
|
||||
invalidateCustomFieldNames()
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Vérifier le typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Test manuel**
|
||||
|
||||
Scénario :
|
||||
1. Ouvrir une machine, ajouter un champ perso « Test invalidation 2026 » et save.
|
||||
2. Ouvrir une autre machine ou un ModelType.
|
||||
3. Tenter d'ajouter un champ perso → taper « Test invalid » → vérifier que « Test invalidation 2026 » apparaît dans les suggestions.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/composables/useMachineCustomFieldDefs.ts frontend/app/components/model-types/ModelTypeForm.vue
|
||||
git commit -m "feat(custom-fields) : invalide le cache de suggestions apres save
|
||||
|
||||
Apres chaque save reussi de champs perso (machine ou ModelType), on
|
||||
invalide le cache useCustomFieldNameSuggestions pour que les noms
|
||||
nouvellement crees apparaissent dans les futures autocomplete."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Validation finale
|
||||
|
||||
**Files:** aucun changement, juste vérification end-to-end.
|
||||
|
||||
- [ ] **Step 1: Lancer le typecheck complet**
|
||||
|
||||
```bash
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
Attendu : 0 errors.
|
||||
|
||||
- [ ] **Step 2: Lancer le linter complet**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint:fix
|
||||
```
|
||||
|
||||
Attendu : 0 errors (ou seulement des fixes auto).
|
||||
|
||||
- [ ] **Step 3: Test end-to-end manuel**
|
||||
|
||||
Démarrer l'environnement local (`make start` si pas déjà fait), puis :
|
||||
|
||||
1. **Machine** : créer une machine, ajouter 2 champs perso « Numéro de série » et « Tension ». Save.
|
||||
2. **ModelType** : créer un ModelType de catégorie composant, ajouter une pièce dans le skeleton, ajouter à cette pièce un champ perso. Vérifier que « Numéro de série » et « Tension » apparaissent dans les suggestions.
|
||||
3. **Structure** : créer un ModelType de catégorie machine, naviguer dans la structure, ajouter un champ perso à un composant. Vérifier les suggestions.
|
||||
4. **Création libre** : taper un nom inédit, voir la ligne « Créer ... », cliquer ou faire blur → garder le texte.
|
||||
5. **Sélection** : cliquer sur une suggestion → input se remplit avec le nom exact.
|
||||
|
||||
- [ ] **Step 4: Vérifier le commit log**
|
||||
|
||||
```bash
|
||||
git log --oneline -15
|
||||
```
|
||||
|
||||
Confirmer qu'on a bien 1 commit par task, avec des messages cohérents.
|
||||
|
||||
- [ ] **Step 5: Push (optionnel, à confirmer avec l'utilisateur)**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
⚠️ Ne PAS push sans demande explicite de l'utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## Récapitulatif des fichiers
|
||||
|
||||
### Créés
|
||||
- `src/Controller/CustomFieldNamesController.php`
|
||||
- `tests/Api/Controller/CustomFieldNamesControllerTest.php`
|
||||
- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
|
||||
- `frontend/app/components/common/CustomFieldNameInput.vue`
|
||||
|
||||
### Modifiés
|
||||
- `frontend/app/components/common/SearchSelect.vue`
|
||||
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
|
||||
- `frontend/app/components/machine/MachineCustomFieldsCard.vue`
|
||||
- `frontend/app/components/PieceModelStructureEditor.vue`
|
||||
- `frontend/app/components/StructureNodeEditor.vue`
|
||||
- `frontend/app/composables/useMachineCustomFieldDefs.ts`
|
||||
- `frontend/app/components/model-types/ModelTypeForm.vue`
|
||||
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
|
||||
@@ -0,0 +1,273 @@
|
||||
# Custom Field Name Autocomplete — Design
|
||||
|
||||
**Date** : 2026-05-11
|
||||
**Statut** : Design validé, prêt pour planification
|
||||
|
||||
## Contexte et problème
|
||||
|
||||
Aujourd'hui dans Inventory, on définit des "champs personnalisés" (custom fields) à plusieurs endroits :
|
||||
- Au niveau d'une **machine** (entité `CustomField` avec FK `machineId`)
|
||||
- Au niveau d'un **ModelType**, dans 3 contextes (composant / pièce / produit) : entité `CustomField` avec respectivement `typeComposantId`, `typePieceId`, `typeProductId`.
|
||||
|
||||
Côté frontend, l'éditeur de structure d'un ModelType expose des `customFields` array sur chaque node, mais lors du save le backend (`SkeletonStructureService::updateCustomFields`) traduit ça en entités `CustomField` persistées dans la table unique `custom_fields`. La table `custom_fields` est donc **l'unique source de vérité** pour tous les noms de champs perso de l'application.
|
||||
|
||||
À chaque création/modification, l'utilisateur saisit librement un **nom** dans un `<input>` texte. Conséquence : les mêmes concepts métier finissent écrits différemment (« Numéro de série », « N° série », « Num serie »), ce qui empêche toute uniformisation et complique les rapports/recherches.
|
||||
|
||||
**Objectif** : proposer une autocomplétion sur le nom du champ qui suggère les noms déjà existants dans la base, tout en autorisant la création libre d'un nouveau nom.
|
||||
|
||||
## Décisions clés
|
||||
|
||||
| Question | Choix retenu |
|
||||
|----------|--------------|
|
||||
| Scope des suggestions | **Cross-entité** (machine + composant + pièce + produit confondus) — objectif d'uniformisation globale |
|
||||
| Comportement utilisateur | **Création libre** : si l'utilisateur tape un nom sans cliquer sur une suggestion, on garde son texte tel quel |
|
||||
| Suggestion du type | Non : la suggestion porte uniquement sur le nom |
|
||||
| Compteur d'usage | Non : on reste simple, juste les noms triés alpha |
|
||||
| Pattern UI | **Étendre `SearchSelect.vue`** existant avec un prop `creatable` plutôt que datalist natif ou nouveau composant — cohérence visuelle avec le reste de l'app |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Backend Frontend
|
||||
───────── ─────────
|
||||
GET /api/custom-fields/names ◄── useCustomFieldNameSuggestions()
|
||||
│ │ (cache module-level)
|
||||
│ returns: ["Numéro...", ...] │
|
||||
▼ ▼
|
||||
SELECT DISTINCT name CustomFieldNameInput.vue (wrapper)
|
||||
FROM custom_fields │
|
||||
│ utilise
|
||||
▼
|
||||
SearchSelect.vue (creatable=true)
|
||||
▲
|
||||
│ utilisé par
|
||||
│
|
||||
┌───────────────┼─────────────────────┐
|
||||
│ │ │
|
||||
MachineCustomFieldDef- StructureNodeEditor PieceModelStructure-
|
||||
Editor (composants) Editor (pièces)
|
||||
│
|
||||
MachineCustomFieldsCard
|
||||
(édition inline d'une machine)
|
||||
```
|
||||
|
||||
## Backend
|
||||
|
||||
### Nouveau endpoint : `GET /api/custom-fields/names`
|
||||
|
||||
**Fichier** : `src/Controller/CustomFieldNamesController.php`
|
||||
|
||||
**Sécurité** : `ROLE_VIEWER` (cohérent avec les autres GET sur `CustomField`).
|
||||
|
||||
**Format de réponse** : tableau JSON plat de strings, trié alphabétique, dédupliqué (case-insensitive sur l'union).
|
||||
|
||||
```json
|
||||
["Numéro de série", "Puissance", "Tension nominale"]
|
||||
```
|
||||
|
||||
> Pas de wrapper `hydra:` — ce n'est pas une resource API Platform mais un endpoint utilitaire.
|
||||
|
||||
### Implémentation SQL
|
||||
|
||||
Le controller exécute une seule requête SQL brute via `Doctrine\DBAL\Connection` :
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT name FROM custom_fields
|
||||
WHERE name IS NOT NULL AND name <> ''
|
||||
ORDER BY name ASC
|
||||
```
|
||||
|
||||
> Toutes les sources de noms (machines, ModelType×composant/pièce/produit) convergent dans la même table `custom_fields` via les FKs `machineId`/`typeComposantId`/`typePieceId`/`typeProductId`. Pas de jointure ni de parsing JSON nécessaire — un simple `SELECT DISTINCT` suffit.
|
||||
|
||||
### Pas de cache HTTP
|
||||
|
||||
La liste change quand un utilisateur crée un nouveau champ perso. Le cache se fait côté frontend (cf. composable). Pas de header `Cache-Control` particulier.
|
||||
|
||||
## Frontend
|
||||
|
||||
### 1. Extension de `SearchSelect.vue`
|
||||
|
||||
**Nouveau prop** :
|
||||
|
||||
```js
|
||||
creatable: {
|
||||
type: Boolean,
|
||||
default: false // strict par défaut → zéro régression sur les 10+ usages actuels
|
||||
}
|
||||
```
|
||||
|
||||
**Changements de comportement quand `creatable=true`** :
|
||||
|
||||
| Aspect | Mode strict (défaut) | Mode `creatable` |
|
||||
|--------|---------------------|------------------|
|
||||
| `modelValue` | ID de l'option | **Texte libre** (le nom est la valeur) |
|
||||
| `handleInput` | emit `'search'` uniquement | emit aussi `'update:modelValue'` en temps réel |
|
||||
| `closeDropdown` (blur) | reset au label de l'option sélectionnée | **garde** le texte tapé |
|
||||
| Dropdown | liste filtrée | liste filtrée + une ligne **« Créer XYZ »** en bas si le texte tapé ne matche aucune option (icône `+`, texte plus discret) |
|
||||
| Clavier | ↑/↓/Enter sélectionne une option | ↑/↓ navigue, Enter valide soit l'option soit le « Créer XYZ » |
|
||||
|
||||
**Garanti** : mode strict 100% inchangé → les 10+ usages actuels de `SearchSelect` ne sont pas affectés.
|
||||
|
||||
### 2. Composable `useCustomFieldNameSuggestions`
|
||||
|
||||
**Fichier** : `frontend/app/composables/useCustomFieldNameSuggestions.ts`
|
||||
|
||||
```ts
|
||||
import { ref } from 'vue'
|
||||
|
||||
const cache = ref<string[] | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
interface Deps {
|
||||
api: ReturnType<typeof useApi>
|
||||
}
|
||||
|
||||
export function useCustomFieldNameSuggestions(deps: Deps) {
|
||||
const { api } = deps
|
||||
|
||||
async function load(force = false) {
|
||||
if (cache.value && !force) return cache.value
|
||||
if (loading.value) return cache.value ?? []
|
||||
loading.value = true
|
||||
try {
|
||||
cache.value = await api<string[]>('/api/custom-fields/names')
|
||||
return cache.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
cache.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: cache,
|
||||
loading,
|
||||
load,
|
||||
invalidate,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Choix de design** :
|
||||
- **Cache module-level** (déclaré hors de la fonction) → partagé entre toutes les instances du composable, donc une seule requête HTTP pour toute l'app.
|
||||
- **Lazy load** au 1er focus → pas de surcoût au démarrage.
|
||||
- **Invalidation manuelle** via `invalidate()` → appelée après chaque save de champ perso pour rafraîchir.
|
||||
- **Pattern `Deps`** → cohérent avec la convention du projet (`interface Deps`, injection de `useApi`).
|
||||
|
||||
### 3. Composant wrapper `CustomFieldNameInput.vue`
|
||||
|
||||
**Fichier** : `frontend/app/components/common/CustomFieldNameInput.vue`
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<SearchSelect
|
||||
v-model="modelValue"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
option-value="name"
|
||||
option-label="name"
|
||||
creatable
|
||||
size="xs"
|
||||
@focus="ensureLoaded"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SearchSelect from './SearchSelect.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
const { suggestions, load } = useCustomFieldNameSuggestions({ api: useApi() })
|
||||
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
|
||||
const ensureLoaded = () => load()
|
||||
</script>
|
||||
```
|
||||
|
||||
**Pourquoi un wrapper** : encapsule le branchement (load, map, props `creatable`/`option-value`) → impossible de l'oublier dans un consommateur, et tous les paramètres restent uniformes par construction.
|
||||
|
||||
### 4. Migration des éditeurs
|
||||
|
||||
Dans chacun des fichiers ci-dessous, **remplacer le `<input v-model="field.name">`** par :
|
||||
|
||||
```vue
|
||||
<CustomFieldNameInput v-model="field.name" placeholder="Nom du champ" />
|
||||
```
|
||||
|
||||
Fichiers concernés :
|
||||
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (ligne ~36-41)
|
||||
- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (ligne ~57)
|
||||
- `frontend/app/components/PieceModelStructureEditor.vue` (ligne ~97-102)
|
||||
- `frontend/app/components/StructureNodeEditor.vue` (ligne ~106-111)
|
||||
|
||||
> Note : `CustomFieldNameInput` étant dans `components/common/`, il est auto-importé par Nuxt — pas besoin d'`import` dans les consommateurs.
|
||||
|
||||
### 5. Invalidation du cache
|
||||
|
||||
Après chaque save réussi de champs perso, appeler `invalidate()` pour que la prochaine ouverture du dropdown récupère les nouveaux noms.
|
||||
|
||||
| Endroit | Quand |
|
||||
|---------|-------|
|
||||
| `useMachineCustomFieldDefs` (composable existant) | Après PATCH/POST réussi des custom fields machine |
|
||||
| `ModelTypeForm.vue` (save ModelType + skeleton requirements) | Après sauvegarde du ModelType |
|
||||
|
||||
Pattern :
|
||||
```ts
|
||||
const { invalidate } = useCustomFieldNameSuggestions({ api: useApi() })
|
||||
|
||||
async function save() {
|
||||
await api(...) // sauvegarde existante
|
||||
invalidate() // ← nouveau
|
||||
}
|
||||
```
|
||||
|
||||
> On n'a pas besoin d'invalider lors d'une simple modification d'un nom existant (au pire la liste a une suggestion en trop, ce n'est pas un bug). On invalide à chaque save pour rester simple.
|
||||
|
||||
## Comportement utilisateur
|
||||
|
||||
### Cas 1 — Création d'un nouveau champ
|
||||
1. User clique « Ajouter un champ »
|
||||
2. Un input vide apparaît
|
||||
3. User clique dedans → dropdown s'ouvre avec tous les noms existants triés alpha
|
||||
4. User tape « num » → dropdown filtre sur `["Numéro de lot", "Numéro de série"]`
|
||||
5. User clique sur « Numéro de série » → l'input se remplit exactement avec « Numéro de série »
|
||||
6. **OU** user tape « num XYZ » et clique ailleurs → l'input garde « num XYZ », une ligne « Créer 'num XYZ' » lui suggère explicitement la création
|
||||
|
||||
### Cas 2 — Modification d'un nom existant
|
||||
1. User voit un champ existant nommé « Numéro de série »
|
||||
2. User clique dans l'input → dropdown s'ouvre, suggestions filtrées sur « Numéro de série »
|
||||
3. User efface et tape « Tension » → dropdown filtre, il peut sélectionner ou retaper librement
|
||||
4. Pas de fusion automatique des données — chaque champ reste indépendant
|
||||
|
||||
### Cas 3 — Plusieurs inputs visibles en même temps
|
||||
- Toutes les instances partagent le même cache (module-level) → une seule requête HTTP pour la session
|
||||
- Si user crée un champ « Nouveau nom » dans l'input A et passe à l'input B sans rafraîchir, « Nouveau nom » apparaîtra dans les suggestions de B dès que `invalidate()` a été appelé au save
|
||||
|
||||
## Hors-scope
|
||||
|
||||
- **Renommage en cascade** : si on change un nom partout (ex: « Num serie » → « Numéro de série » pour les unifier), pas de migration automatique des champs existants. C'est un travail manuel, ou un futur outil dédié.
|
||||
- **Compteur d'usage** : peut être ajouté plus tard sans changer l'API (format de réponse extensible).
|
||||
- **Suggestion du type** : on ne propose pas un type par défaut quand l'utilisateur sélectionne une suggestion. À évaluer si besoin émerge.
|
||||
- **Tests** : pas de tests Vue dans le projet actuellement → validation manuelle. Côté backend, un test PHPUnit du controller est recommandé (cf. plan d'implémentation).
|
||||
|
||||
## Fichiers impactés (résumé)
|
||||
|
||||
### Nouveaux fichiers
|
||||
- `src/Controller/CustomFieldNamesController.php`
|
||||
- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
|
||||
- `frontend/app/components/common/CustomFieldNameInput.vue`
|
||||
|
||||
### Fichiers modifiés
|
||||
- `frontend/app/components/common/SearchSelect.vue` (ajout prop `creatable`)
|
||||
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (remplacer input)
|
||||
- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (remplacer input)
|
||||
- `frontend/app/components/PieceModelStructureEditor.vue` (remplacer input)
|
||||
- `frontend/app/components/StructureNodeEditor.vue` (remplacer input)
|
||||
- `frontend/app/composables/useMachineCustomFieldDefs.ts` (ajout `invalidate()` après save)
|
||||
- `frontend/app/components/model-types/ModelTypeForm.vue` (ajout `invalidate()` après save)
|
||||
@@ -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._composantconstructeurs (a, b) VALUES ('cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm');
|
||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm');
|
||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj');
|
||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmkqps2h8001q1eq6k2uxopfo', 'cmkqpnznr001p1eq6hdh2ept8');
|
||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmkqyn2jm002m1eq6ws83lgwx', 'cmkqpnznr001p1eq6hdh2ept8');
|
||||
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cl9b1583768c7c9fe6cfe93a11', 'cmkqpnznr001p1eq6hdh2ept8');
|
||||
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.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.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.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.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.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.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._piececonstructeurs (a, b) VALUES ('cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'cmkqpnznr001p1eq6hdh2ept8');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9sk000047uuwm6u20mj');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9te000547uuond39s1c');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9tb000447uuuddgakar');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmhaac3vo003547v7s1wv6jhv');
|
||||
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9tm000647uu6em8thyq');
|
||||
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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
<AppBreadcrumb />
|
||||
|
||||
<main class="flex-1">
|
||||
<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 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)
|
||||
if (result.success) {
|
||||
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<!-- 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
|
||||
:component="component"
|
||||
:is-edit-mode="isEditMode"
|
||||
|
||||
@@ -13,17 +13,47 @@
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<!-- Component Header -->
|
||||
<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">
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
|
||||
:class="{ 'rotate-90': !isCollapsed }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- ═══ HEADER BAR ═══ -->
|
||||
<div
|
||||
class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
|
||||
:class="[
|
||||
component.pendingEntity
|
||||
? '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',
|
||||
!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">
|
||||
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||
{{ component.name }}
|
||||
<h3 class="text-sm font-bold tracking-tight truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||
<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>
|
||||
<button
|
||||
v-if="component.pendingEntity"
|
||||
@@ -34,232 +64,287 @@
|
||||
>
|
||||
À remplir
|
||||
</button>
|
||||
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
|
||||
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}€</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.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 v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
|
||||
|
||||
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
|
||||
<div
|
||||
v-if="visibleContextFieldTags.length"
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span
|
||||
v-for="field in visibleContextFieldTags"
|
||||
:key="field.name"
|
||||
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
|
||||
:class="contextFieldBadgeClass(field)"
|
||||
>
|
||||
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
|
||||
<span class="text-sm font-bold">{{ field.value }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Metadata tags -->
|
||||
<div
|
||||
v-if="componentConstructeursDisplay.length || displayProductName"
|
||||
class="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-for="constructeur in componentConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="text-xs text-base-content/50"
|
||||
class="text-[0.65rem] text-base-content/45"
|
||||
>
|
||||
{{ 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 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 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
v-if="showDelete"
|
||||
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"
|
||||
@click.stop="$emit('delete')"
|
||||
>
|
||||
Supprimer
|
||||
<IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Expanded content -->
|
||||
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7">
|
||||
<!-- Info fields -->
|
||||
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
|
||||
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||
<!-- ═══ EXPANDED PANEL ═══ -->
|
||||
<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">
|
||||
|
||||
<!-- ── Section: Informations ── -->
|
||||
<div 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">Informations</p>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
|
||||
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||
<div class="p-4">
|
||||
<!-- Edit mode -->
|
||||
<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 class="form-control">
|
||||
<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">
|
||||
</div>
|
||||
|
||||
<!-- ── 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 class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
|
||||
<ConstructeurSelect
|
||||
class="w-full"
|
||||
:model-value="componentConstructeurIds"
|
||||
:initial-options="componentConstructeursDisplay"
|
||||
@update:model-value="handleConstructeurChange"
|
||||
<div class="p-4">
|
||||
<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="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>
|
||||
|
||||
<!-- Read-only info -->
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
|
||||
<p class="text-base-content">{{ component.name }}</p>
|
||||
<div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||
<div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
|
||||
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/40 mb-0.5">Référence</p>
|
||||
<p class="text-base-content">{{ component.reference || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/40 mb-0.5">Prix</p>
|
||||
<p class="text-base-content">{{ component.prix ? `${component.prix} €` : '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p>
|
||||
<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 class="p-4">
|
||||
<CustomFieldDisplay
|
||||
:fields="mergedContextFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
:columns="2"
|
||||
:show-header="false"
|
||||
:with-top-border="false"
|
||||
:editable="true"
|
||||
:emit-blur="false"
|
||||
@field-input="queueContextCustomFieldUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product -->
|
||||
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<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>
|
||||
<!-- ── Section: Documents ── -->
|
||||
<div 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 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...</p>
|
||||
|
||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
||||
Chargement...
|
||||
</p>
|
||||
<DocumentUpload
|
||||
v-if="isEditMode"
|
||||
v-model="selectedFiles"
|
||||
title="Déposer des fichiers pour ce composant"
|
||||
subtitle="Formats acceptés : PDF, images, documents..."
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
|
||||
<DocumentUpload
|
||||
v-if="isEditMode"
|
||||
v-model="selectedFiles"
|
||||
title="Déposer des fichiers pour ce composant"
|
||||
subtitle="Formats acceptés : PDF, images, documents..."
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
|
||||
<DocumentListInline
|
||||
:documents="componentDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:can-edit="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à ce composant."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
<DocumentListInline
|
||||
:documents="componentDocuments"
|
||||
:can-delete="isEditMode"
|
||||
: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) -->
|
||||
<div v-if="linkedPieces.length > 0" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||
Pièces du composant
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<!-- ── Section: Pièces du composant ── -->
|
||||
<div v-if="linkedPieces.length > 0" 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">
|
||||
Pièces du composant
|
||||
<span class="ml-1 text-base-content/25">({{ linkedPieces.length }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 space-y-2">
|
||||
<PieceItem
|
||||
v-for="piece in linkedPieces"
|
||||
:key="piece.id"
|
||||
@@ -273,12 +358,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Structure pieces (read-only, from composant definition) -->
|
||||
<div v-if="structurePieces.length > 0" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||
Pièces incluses par défaut
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<!-- ── Section: Pièces structure ── -->
|
||||
<div v-if="structurePieces.length > 0" 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">
|
||||
Pièces incluses par défaut
|
||||
<span class="ml-1 text-base-content/25">({{ structurePieces.length }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 space-y-2">
|
||||
<PieceItem
|
||||
v-for="piece in structurePieces"
|
||||
:key="piece.id"
|
||||
@@ -288,12 +376,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub Components -->
|
||||
<div v-if="childComponents.length > 0" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||
Sous-composants
|
||||
</p>
|
||||
<div class="space-y-2 pl-4 border-l-2 border-base-200">
|
||||
<!-- ── Section: Sous-composants ── -->
|
||||
<div v-if="childComponents.length > 0" 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">
|
||||
Sous-composants
|
||||
<span class="ml-1 text-base-content/25">({{ childComponents.length }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 space-y-2">
|
||||
<ComponentItem
|
||||
v-for="subComponent in childComponents"
|
||||
:key="subComponent.id"
|
||||
@@ -312,13 +403,15 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import PieceItem from './PieceItem.vue'
|
||||
import DocumentUpload from './DocumentUpload.vue'
|
||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
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 { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import {
|
||||
@@ -335,13 +428,11 @@ import {
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
import {
|
||||
mergeFieldDefinitionsWithValues,
|
||||
dedupeMergedFields,
|
||||
resolveCustomFieldId,
|
||||
resolveFieldId,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
|
||||
|
||||
const route = useRoute()
|
||||
const machineId = computed(() => route.params.id as string | undefined)
|
||||
|
||||
const props = defineProps({
|
||||
component: { type: Object, required: true },
|
||||
@@ -377,25 +468,99 @@ const {
|
||||
} = useEntityProductDisplay({ entity: () => props.component })
|
||||
|
||||
const {
|
||||
displayedCustomFields,
|
||||
updateCustomField: updateComponentCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||
upsertCustomFieldValue,
|
||||
} = 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 definitions = props.component?.contextCustomFields ?? []
|
||||
const values = props.component?.contextCustomFieldValues ?? []
|
||||
if (!definitions.length && !values.length) return []
|
||||
return dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(definitions, values),
|
||||
)
|
||||
return mergeDefinitionsWithValues(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 linkId = props.component?.linkId
|
||||
if (!linkId || !field) return
|
||||
|
||||
const customFieldId = resolveCustomFieldId(field)
|
||||
const customFieldValueId = resolveFieldId(field)
|
||||
const customFieldId = field.customFieldId
|
||||
const customFieldValueId = field.customFieldValueId
|
||||
if (!customFieldId && !customFieldValueId) return
|
||||
|
||||
field.value = value
|
||||
@@ -405,7 +570,7 @@ const queueContextCustomFieldUpdate = (field, value) => {
|
||||
fieldId: customFieldId,
|
||||
customFieldValueId,
|
||||
value: value ?? '',
|
||||
fieldName: field.name || field.customField?.name || 'Champ contextuel',
|
||||
fieldName: field.name || 'Champ contextuel',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ import IconLucideCheck from '~icons/lucide/check'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import {
|
||||
type ConstructeurSummary,
|
||||
constructeurPhones,
|
||||
formatConstructeurContact,
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
@@ -193,7 +194,7 @@ const filteredOptions = computed(() => {
|
||||
return options.value.filter((option) =>
|
||||
(option.name ?? '').toLowerCase().includes(term)
|
||||
|| (option.email && option.email.toLowerCase().includes(term))
|
||||
|| (option.phone && option.phone.toLowerCase().includes(term))
|
||||
|| constructeurPhones(option).some(t => t.numero.toLowerCase().includes(term))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -293,14 +294,14 @@ const handleCreate = async () => {
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
const payload: { name: string; email?: string; phone?: string } = {
|
||||
const payload: { name: string; email?: string; telephones?: Array<{ numero: string }> } = {
|
||||
name: trimmedName,
|
||||
}
|
||||
if (createForm.value.email) {
|
||||
payload.email = createForm.value.email
|
||||
}
|
||||
if (createForm.value.phone) {
|
||||
payload.phone = createForm.value.phone
|
||||
if (createForm.value.phone && createForm.value.phone.trim()) {
|
||||
payload.telephones = [{ numero: createForm.value.phone.trim() }]
|
||||
}
|
||||
const result = await createConstructeur(payload)
|
||||
creating.value = false
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
Retour au catalogue
|
||||
</button>
|
||||
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md">
|
||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
{{ backLabel }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -25,8 +26,9 @@
|
||||
<script setup lang="ts">
|
||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
@@ -34,18 +36,24 @@ const props = defineProps<{
|
||||
isEditMode: boolean
|
||||
canEdit: boolean
|
||||
backLink: string
|
||||
backLinkLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-edit': []
|
||||
}>()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
const backDestination = computed(() => {
|
||||
if (route.query.from === 'machine' && route.query.machineId) {
|
||||
return `/machine/${route.query.machineId}`
|
||||
}
|
||||
else {
|
||||
navigateTo(props.backLink)
|
||||
return props.backLink
|
||||
})
|
||||
|
||||
const backLabel = computed(() => {
|
||||
if (route.query.from === 'machine') {
|
||||
return 'Retour à la machine'
|
||||
}
|
||||
}
|
||||
return props.backLinkLabel ?? 'Retour au catalogue'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
@@ -13,304 +13,351 @@
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||
<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 class="flex items-start gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
||||
:class="{ 'rotate-90': !isCollapsed }"
|
||||
:aria-expanded="!isCollapsed"
|
||||
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
||||
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
|
||||
{{ pieceData.name }}
|
||||
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1">— manquant</span>
|
||||
<button
|
||||
v-if="piece.pendingEntity"
|
||||
type="button"
|
||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors ml-1"
|
||||
title="Cliquer pour associer un item"
|
||||
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
|
||||
<!-- ═══ HEADER BAR ═══ -->
|
||||
<div
|
||||
class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
|
||||
:class="[
|
||||
piece._emptySlot || piece.pendingEntity
|
||||
? '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',
|
||||
!isCollapsed ? '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">
|
||||
<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
|
||||
</button>
|
||||
<span
|
||||
v-if="displayQuantity > 1"
|
||||
class="text-sm font-normal text-base-content/60 ml-1"
|
||||
>
|
||||
×{{ displayQuantity }}
|
||||
</span>
|
||||
{{ pieceData.name }}
|
||||
</NuxtLink>
|
||||
<template v-else>{{ pieceData.name }}</template>
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
|
||||
Rattachée à {{ piece.parentComponentName }}
|
||||
</span>
|
||||
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
|
||||
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
||||
<template v-if="pieceConstructeursDisplay.length">
|
||||
<span
|
||||
v-for="constructeur in pieceConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
|
||||
({{ supplierReferenceMap.get(constructeur.id) }})
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}€</span>
|
||||
<span
|
||||
v-if="displayProductName"
|
||||
class="badge badge-info badge-sm"
|
||||
>
|
||||
Produit : {{ displayProductName }}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
<button
|
||||
v-if="piece.pendingEntity"
|
||||
type="button"
|
||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
|
||||
title="Cliquer pour associer un item"
|
||||
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
|
||||
>
|
||||
À remplir
|
||||
</button>
|
||||
<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>
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
|
||||
<div
|
||||
v-if="visibleContextFieldTags.length"
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span
|
||||
v-for="field in visibleContextFieldTags"
|
||||
:key="field.name"
|
||||
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
|
||||
:class="contextFieldBadgeClass(field)"
|
||||
>
|
||||
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
|
||||
<span class="text-sm font-bold">{{ field.value }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Metadata tags -->
|
||||
<div
|
||||
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
v-if="showDelete"
|
||||
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"
|
||||
@click="$emit('delete')"
|
||||
@click.stop="$emit('delete')"
|
||||
>
|
||||
Supprimer
|
||||
<IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4">
|
||||
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="isEditMode" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">Quantité</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="pieceData.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input input-bordered input-sm md:input-md w-24"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="displayQuantity > 1">
|
||||
<span class="font-medium">Quantité:</span>
|
||||
<span class="ml-2">{{ displayQuantity }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Référence:</span>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
:id="`piece-reference-${piece.id}`"
|
||||
v-model="pieceData.reference"
|
||||
type="text"
|
||||
class="input input-sm input-bordered ml-2"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
<span v-else class="ml-2">{{
|
||||
pieceData.reference || "Non définie"
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="pieceData.referenceAuto">
|
||||
<span class="font-medium">Référence auto:</span>
|
||||
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Fournisseur:</span>
|
||||
<div v-if="!isEditMode" class="ml-2">
|
||||
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
|
||||
<div
|
||||
v-for="constructeur in pieceConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<span class="font-medium">
|
||||
{{ constructeur.name }}
|
||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60">
|
||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="formatConstructeurContact(constructeur)"
|
||||
class="text-xs text-base-content/50"
|
||||
>
|
||||
{{ formatConstructeurContact(constructeur) }}
|
||||
</span>
|
||||
<!-- ═══ EXPANDED PANEL ═══ -->
|
||||
<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">
|
||||
|
||||
<!-- ── Section: Informations ── -->
|
||||
<div 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">Informations</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<!-- Edit mode -->
|
||||
<div v-if="isEditMode" class="space-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 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">Quantité</span></label>
|
||||
<input
|
||||
v-model.number="pieceData.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input input-bordered input-sm w-full"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
</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
|
||||
:id="`piece-reference-${piece.id}`"
|
||||
v-model="pieceData.reference"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
</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
|
||||
:id="`piece-prix-${piece.id}`"
|
||||
v-model="pieceData.prix"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm w-full"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
</div>
|
||||
</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="pieceConstructeurIds"
|
||||
:initial-options="pieceConstructeursDisplay"
|
||||
placeholder="Sélectionner un ou plusieurs fournisseurs..."
|
||||
@update:model-value="handleConstructeurChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="font-medium">
|
||||
Non défini
|
||||
</span>
|
||||
</div>
|
||||
<ConstructeurSelect
|
||||
v-else
|
||||
class="w-full"
|
||||
:model-value="pieceConstructeurIds"
|
||||
:initial-options="pieceConstructeursDisplay"
|
||||
placeholder="Sélectionner un ou plusieurs fournisseurs..."
|
||||
@update:model-value="handleConstructeurChange"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Prix:</span>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
:id="`piece-prix-${piece.id}`"
|
||||
v-model="pieceData.prix"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered ml-2"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
<span v-else class="ml-2">{{
|
||||
pieceData.prix ? `${pieceData.prix}€` : "Non défini"
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Produit catalogue:</span>
|
||||
<div v-if="isEditMode" class="mt-2 space-y-2">
|
||||
<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-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>
|
||||
<!-- Read-only mode -->
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||
<div v-if="displayQuantity > 1">
|
||||
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Quantité</p>
|
||||
<p class="text-sm text-base-content font-medium">{{ displayQuantity }}</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" :class="pieceData.reference ? 'text-base-content font-mono' : 'text-base-content/30'">{{ pieceData.reference || '—' }}</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="text-sm text-base-content font-mono">{{ pieceData.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="pieceData.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ pieceData.prix ? `${pieceData.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="pieceConstructeursDisplay.length" class="space-y-1">
|
||||
<p
|
||||
v-for="constructeur in pieceConstructeursDisplay"
|
||||
: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>
|
||||
<p v-else class="text-xs text-base-content/60">
|
||||
Aucun produit associé.
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<div v-if="displayProduct" class="space-y-1">
|
||||
<p class="font-medium text-base-content">
|
||||
{{ displayProductName || 'Produit catalogue' }}
|
||||
</p>
|
||||
<p
|
||||
v-for="info in productInfoRows"
|
||||
:key="info.label"
|
||||
class="text-xs text-base-content/70"
|
||||
</div>
|
||||
|
||||
<!-- ── Section: Produit catalogue ── -->
|
||||
<div v-if="isEditMode || 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 class="p-4">
|
||||
<!-- 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>
|
||||
<span class="ml-1">{{ info.value }}</span>
|
||||
</p>
|
||||
<p class="text-sm font-bold text-base-content">{{ selectedProduct.name }}</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>
|
||||
<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
|
||||
v-if="productDocuments.length"
|
||||
class="mt-3 pt-3 border-t border-base-200/50"
|
||||
:documents="productDocuments"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="font-medium">
|
||||
Non défini
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<!-- Champs personnalisés de la pièce -->
|
||||
<CustomFieldDisplay
|
||||
:fields="displayedCustomFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
title="Champs personnalisés item"
|
||||
:editable="false"
|
||||
@field-input="handleCustomFieldInput"
|
||||
@field-blur="handleCustomFieldBlur"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<!-- ── Section: Champs personnalisés machine ── -->
|
||||
<div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||
<div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
|
||||
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<CustomFieldDisplay
|
||||
:fields="mergedContextFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
:columns="2"
|
||||
:show-header="false"
|
||||
:with-top-border="false"
|
||||
:editable="true"
|
||||
:emit-blur="false"
|
||||
@field-input="queueContextCustomFieldUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
||||
Chargement des documents...
|
||||
</p>
|
||||
<!-- ── Section: Documents ── -->
|
||||
<div 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 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
|
||||
v-if="isEditMode"
|
||||
v-model="selectedFiles"
|
||||
title="Déposer des fichiers pour cette pièce"
|
||||
subtitle="Formats acceptés : PDF, images, documents..."
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
<DocumentUpload
|
||||
v-if="isEditMode"
|
||||
v-model="selectedFiles"
|
||||
title="Déposer des fichiers pour cette pièce"
|
||||
subtitle="Formats acceptés : PDF, images, documents..."
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
|
||||
<DocumentListInline
|
||||
:documents="pieceDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:can-edit="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à cette pièce."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
<DocumentListInline
|
||||
:documents="pieceDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:can-edit="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à cette pièce."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, watch, computed } from 'vue'
|
||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||
import ProductSelect from '~/components/ProductSelect.vue'
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
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 { useProducts } from '~/composables/useProducts'
|
||||
import {
|
||||
@@ -319,16 +366,13 @@ import {
|
||||
uniqueConstructeurIds,
|
||||
parseConstructeurLinksFromApi,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
resolveFieldId,
|
||||
resolveFieldReadOnly,
|
||||
resolveCustomFieldId,
|
||||
mergeFieldDefinitionsWithValues,
|
||||
dedupeMergedFields,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
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({
|
||||
piece: { type: Object, required: true },
|
||||
@@ -392,25 +436,99 @@ const {
|
||||
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
|
||||
|
||||
const {
|
||||
displayedCustomFields,
|
||||
updateCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||
upsertCustomFieldValue,
|
||||
} = 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 definitions = props.piece?.contextCustomFields ?? []
|
||||
const values = props.piece?.contextCustomFieldValues ?? []
|
||||
if (!definitions.length && !values.length) return []
|
||||
return dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(definitions, values),
|
||||
)
|
||||
return mergeDefinitionsWithValues(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 linkId = props.piece?.linkId
|
||||
if (!linkId || !field) return
|
||||
|
||||
const customFieldId = resolveCustomFieldId(field)
|
||||
const customFieldValueId = resolveFieldId(field)
|
||||
const customFieldId = field.customFieldId
|
||||
const customFieldValueId = field.customFieldValueId
|
||||
if (!customFieldId && !customFieldValueId) return
|
||||
|
||||
field.value = value
|
||||
@@ -420,7 +538,7 @@ const queueContextCustomFieldUpdate = (field, value) => {
|
||||
fieldId: customFieldId,
|
||||
customFieldValueId,
|
||||
value: value ?? '',
|
||||
fieldName: field.name || field.customField?.name || 'Champ contextuel',
|
||||
fieldName: field.name || 'Champ contextuel',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -544,8 +662,8 @@ const handleProductChange = async (value) => {
|
||||
|
||||
// --- Custom field event handlers ---
|
||||
const handleCustomFieldInput = (field, value) => {
|
||||
if (resolveFieldReadOnly(field)) return
|
||||
const fieldValueId = resolveFieldId(field)
|
||||
if (field.readOnly) return
|
||||
const fieldValueId = field.customFieldValueId
|
||||
if (!fieldValueId) return
|
||||
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
|
||||
if (fieldValue) fieldValue.value = value
|
||||
@@ -553,7 +671,7 @@ const handleCustomFieldInput = (field, value) => {
|
||||
|
||||
const handleCustomFieldBlur = async (field) => {
|
||||
await updateCustomField(field)
|
||||
const cfId = field?.customFieldId || field?.customField?.id || null
|
||||
const cfId = field?.customFieldId || null
|
||||
if (cfId || field?.customFieldValueId) {
|
||||
emit('custom-field-update', {
|
||||
fieldId: cfId,
|
||||
@@ -626,12 +744,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
pieceData.name = props.piece.name || ''
|
||||
pieceData.reference = props.piece.reference || ''
|
||||
pieceData.prix = props.piece.prix || ''
|
||||
pieceData.quantity = props.piece.quantity ?? 1
|
||||
loadProducts().catch(() => {})
|
||||
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
|
||||
if (!props.piece.documents?.length) refreshDocuments()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="space-y-3">
|
||||
<section v-if="!hideProducts" class="space-y-3">
|
||||
<header>
|
||||
<h3 class="text-sm font-semibold">
|
||||
Produits inclus par défaut
|
||||
@@ -94,12 +94,11 @@
|
||||
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<input
|
||||
<CustomFieldNameInput
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
size="xs"
|
||||
/>
|
||||
<select v-model="field.type" class="select select-bordered select-xs">
|
||||
<option value="text">
|
||||
Texte
|
||||
@@ -166,6 +165,7 @@ defineOptions({ name: 'PieceModelStructureEditor' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: PieceModelStructure | null
|
||||
hideProducts?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -103,11 +103,10 @@
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<input
|
||||
<CustomFieldNameInput
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
size="xs"
|
||||
/>
|
||||
<select v-model="field.type" class="select select-bordered select-xs">
|
||||
<option value="text">Texte</option>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
]"
|
||||
>
|
||||
<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)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -54,13 +54,20 @@
|
||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</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>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
@@ -70,7 +77,7 @@ import IconLucideInfo from '~icons/lucide/info'
|
||||
|
||||
const { toasts, removeToast } = useToast()
|
||||
|
||||
const getToastClasses = (type) => {
|
||||
const getToastClasses = (type: ToastType) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'alert-success text-success-content'
|
||||
@@ -111,4 +118,9 @@ const getToastClasses = (type) => {
|
||||
pointer-events: auto;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
@keyframes toast-progress {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
<div :class="layoutClass">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="resolveFieldKey(field, index)"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{
|
||||
resolveFieldName(field)
|
||||
field.name
|
||||
}}</span>
|
||||
<span
|
||||
v-if="resolveFieldRequired(field)"
|
||||
v-if="field.required"
|
||||
class="label-text-alt text-error"
|
||||
>*</span>
|
||||
</label>
|
||||
@@ -26,32 +26,32 @@
|
||||
<template v-if="isFieldEditable(field)">
|
||||
<!-- Champ de type TEXT -->
|
||||
<input
|
||||
v-if="resolveFieldType(field) === 'text'"
|
||||
v-if="field.type === 'text'"
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type NUMBER -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'number'"
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="field.value ?? ''"
|
||||
type="number"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type SELECT -->
|
||||
<select
|
||||
v-else-if="resolveFieldType(field) === 'select'"
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="field.value ?? ''"
|
||||
class="select select-bordered select-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@change="onInput(field, ($event.target as HTMLSelectElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
@@ -59,7 +59,7 @@
|
||||
Sélectionner...
|
||||
</option>
|
||||
<option
|
||||
v-for="option in resolveFieldOptions(field)"
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
<!-- Champ de type BOOLEAN -->
|
||||
<div
|
||||
v-else-if="resolveFieldType(field) === 'boolean'"
|
||||
v-else-if="field.type === 'boolean'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
@@ -85,21 +85,21 @@
|
||||
|
||||
<!-- Champ de type DATE -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'date'"
|
||||
v-else-if="field.type === 'date'"
|
||||
:value="field.value ?? ''"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type TEXTAREA -->
|
||||
<textarea
|
||||
v-else-if="resolveFieldType(field) === 'textarea'"
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:value="field.value ?? ''"
|
||||
class="textarea textarea-bordered textarea-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@input="onInput(field, ($event.target as HTMLTextAreaElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
/>
|
||||
@@ -110,7 +110,7 @@
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
:required="field.required"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
@@ -119,7 +119,7 @@
|
||||
<!-- Mode lecture seule -->
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatFieldDisplayValue(field) }}
|
||||
{{ formatValueForDisplay(field) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -128,18 +128,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
resolveFieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldOptions,
|
||||
resolveFieldRequired,
|
||||
resolveFieldReadOnly,
|
||||
formatFieldDisplayValue,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { fieldKey, formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/customFields'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: any[]
|
||||
fields: CustomFieldInput[]
|
||||
isEditMode: boolean
|
||||
columns?: 1 | 2
|
||||
title?: string
|
||||
@@ -150,8 +142,8 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'field-input': [field: any, value: string]
|
||||
'field-blur': [field: any]
|
||||
'field-input': [field: CustomFieldInput, value: string]
|
||||
'field-blur': [field: CustomFieldInput]
|
||||
}>()
|
||||
|
||||
const layoutClass = computed(() =>
|
||||
@@ -170,16 +162,16 @@ const containerClass = computed(() =>
|
||||
const editable = computed(() => props.editable ?? true)
|
||||
const emitBlur = computed(() => props.emitBlur ?? true)
|
||||
|
||||
function isFieldEditable(field: any) {
|
||||
return props.isEditMode && editable.value && !resolveFieldReadOnly(field)
|
||||
function isFieldEditable(field: CustomFieldInput) {
|
||||
return props.isEditMode && editable.value && !field.readOnly
|
||||
}
|
||||
|
||||
function onInput(field: any, value: string) {
|
||||
function onInput(field: CustomFieldInput, value: string) {
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
}
|
||||
|
||||
function onBooleanChange(field: any, checked: boolean) {
|
||||
function onBooleanChange(field: CustomFieldInput, checked: boolean) {
|
||||
const value = checked ? 'true' : 'false'
|
||||
field.value = 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) {
|
||||
emit('field-blur', field)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
|
||||
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
|
||||
|
||||
defineProps<{
|
||||
fields: CustomFieldInput[]
|
||||
|
||||
43
frontend/app/components/common/CustomFieldNameInput.vue
Normal file
43
frontend/app/components/common/CustomFieldNameInput.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<SearchSelect
|
||||
:model-value="modelValue"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
option-value="name"
|
||||
option-label="name"
|
||||
creatable
|
||||
:size="size"
|
||||
@update:model-value="onUpdate"
|
||||
@focus="ensureLoaded"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import SearchSelect from './SearchSelect.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
}>(), {
|
||||
placeholder: 'Nom du champ',
|
||||
size: 'xs',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { suggestions, load } = useCustomFieldNameSuggestions()
|
||||
|
||||
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
|
||||
|
||||
function ensureLoaded(): void {
|
||||
void load()
|
||||
}
|
||||
|
||||
function onUpdate(value: string | number): void {
|
||||
emit('update:modelValue', String(value ?? ''))
|
||||
}
|
||||
</script>
|
||||
@@ -71,7 +71,7 @@
|
||||
>
|
||||
<span class="loading loading-spinner text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
<table :class="['table table-sm md:table-md', tableClass]">
|
||||
<table :class="['table table-sm md:table-md', tableClass, { 'table-fixed': fixedLayout }]">
|
||||
<thead>
|
||||
<!-- Header labels + sort -->
|
||||
<tr>
|
||||
@@ -85,6 +85,7 @@
|
||||
alignClass(col),
|
||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||
]"
|
||||
:style="col.minWidth ? { minWidth: col.minWidth } : undefined"
|
||||
>
|
||||
<slot :name="`header-${col.key}`" :column="col">
|
||||
<span
|
||||
@@ -221,6 +222,8 @@ const props = withDefaults(defineProps<{
|
||||
tableClass?: string
|
||||
showCounter?: boolean
|
||||
showPerPage?: boolean
|
||||
/** Use table-layout: fixed for stable column widths. Only enable on tables where columns define width/minWidth. */
|
||||
fixedLayout?: boolean
|
||||
}>(), {
|
||||
rowKey: 'id',
|
||||
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>
|
||||
@@ -77,6 +77,15 @@
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="creatableSuggestion"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
|
||||
@click="confirmCreatable"
|
||||
>
|
||||
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
|
||||
Créer « {{ creatableSuggestion }} »
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
@@ -87,6 +96,7 @@
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -137,10 +147,14 @@ const props = defineProps({
|
||||
serverSearch: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
creatable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search'])
|
||||
const emit = defineEmits(['update:modelValue', 'search', 'focus'])
|
||||
|
||||
const searchTerm = ref('')
|
||||
const openDropdown = ref(false)
|
||||
@@ -172,6 +186,18 @@ const displayedOptions = computed(() => {
|
||||
return filtered
|
||||
})
|
||||
|
||||
const creatableSuggestion = computed(() => {
|
||||
if (!props.creatable) return null
|
||||
const term = searchTerm.value.trim()
|
||||
if (!term) return null
|
||||
// Show "Créer ..." only if no option matches exactly (case-insensitive)
|
||||
const exists = baseOptions.value.some(option => {
|
||||
const label = resolveLabel(option).toLowerCase()
|
||||
return label === term.toLowerCase()
|
||||
})
|
||||
return exists ? null : term
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
||||
const base = ['input', 'input-bordered', 'w-full', pr]
|
||||
@@ -194,6 +220,12 @@ const toggleButtonClasses = computed(() => {
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (props.creatable) {
|
||||
if (searchTerm.value !== props.modelValue) {
|
||||
searchTerm.value = String(props.modelValue ?? '')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!openDropdown.value) {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
}
|
||||
@@ -269,6 +301,7 @@ function handleFocus () {
|
||||
if (searchTerm.value === '' && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
function toggleDropdown () {
|
||||
@@ -285,6 +318,9 @@ function handleInput () {
|
||||
if (!openDropdown.value) {
|
||||
openDropdown.value = true
|
||||
}
|
||||
if (props.creatable) {
|
||||
emit('update:modelValue', searchTerm.value)
|
||||
}
|
||||
emit('search', searchTerm.value)
|
||||
}
|
||||
|
||||
@@ -294,8 +330,18 @@ function clearSelection () {
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function confirmCreatable () {
|
||||
if (creatableSuggestion.value) {
|
||||
emit('update:modelValue', creatableSuggestion.value)
|
||||
}
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function closeDropdown () {
|
||||
openDropdown.value = false
|
||||
if (props.creatable) {
|
||||
return // keep the typed text as-is
|
||||
}
|
||||
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||
emit('update:modelValue', '')
|
||||
} else if (selectedOption.value) {
|
||||
@@ -342,7 +388,11 @@ const handleGlobalClick = (event) => {
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleGlobalClick)
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
if (props.creatable) {
|
||||
searchTerm.value = String(props.modelValue ?? '')
|
||||
} else {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
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>
|
||||
153
frontend/app/components/form/ConstructeurCategorieSelect.vue
Normal file
153
frontend/app/components/form/ConstructeurCategorieSelect.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="constructeur-categorie-select space-y-2">
|
||||
<div class="flex flex-wrap gap-2 min-h-[1.75rem]">
|
||||
<span v-if="!selected.length" class="text-sm text-base-content/50">
|
||||
Aucune catégorie
|
||||
</span>
|
||||
<span
|
||||
v-for="cat in selected"
|
||||
:key="cat.id || cat.name"
|
||||
class="badge badge-outline badge-lg gap-1"
|
||||
>
|
||||
<span>{{ cat.name }}</span>
|
||||
<button
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs p-0 h-auto min-h-0"
|
||||
aria-label="Retirer la catégorie"
|
||||
@click="removeCategory(cat)"
|
||||
>
|
||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!disabled" class="relative">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md w-full"
|
||||
:placeholder="placeholder"
|
||||
@focus="open = true; ensureLoaded()"
|
||||
@keydown.escape="open = false"
|
||||
>
|
||||
<div
|
||||
v-if="open && (matches.length || canCreate)"
|
||||
class="absolute z-30 mt-1 w-full max-h-56 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="cat in matches"
|
||||
:key="cat.id"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm"
|
||||
@click="addCategory(cat)"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canCreate"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm text-primary"
|
||||
@click="createAndAdd"
|
||||
>
|
||||
+ Créer « {{ searchTerm.trim() }} »
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<ConstructeurCategorie[]>,
|
||||
default: () => [],
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Rechercher ou créer une catégorie…',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: ConstructeurCategorie[]): void
|
||||
}>()
|
||||
|
||||
const { categories, loadCategories, createCategory } = useConstructeurCategories()
|
||||
|
||||
const searchTerm = ref('')
|
||||
const open = ref(false)
|
||||
const loadedOnce = ref(false)
|
||||
|
||||
const selected = computed<ConstructeurCategorie[]>(() => props.modelValue || [])
|
||||
|
||||
const selectedKeys = computed(() => new Set(selected.value.map(c => (c.name || '').toLowerCase())))
|
||||
|
||||
const matches = computed<ConstructeurCategorie[]>(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
return categories.value
|
||||
.filter(c => !selectedKeys.value.has((c.name || '').toLowerCase()))
|
||||
.filter(c => !term || (c.name || '').toLowerCase().includes(term))
|
||||
.slice(0, 50)
|
||||
})
|
||||
|
||||
const canCreate = computed(() => {
|
||||
const term = searchTerm.value.trim()
|
||||
if (!term) {
|
||||
return false
|
||||
}
|
||||
const lower = term.toLowerCase()
|
||||
return !categories.value.some(c => (c.name || '').toLowerCase() === lower)
|
||||
&& !selectedKeys.value.has(lower)
|
||||
})
|
||||
|
||||
const ensureLoaded = async () => {
|
||||
if (loadedOnce.value) {
|
||||
return
|
||||
}
|
||||
loadedOnce.value = true
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
const emitSelection = (value: ConstructeurCategorie[]) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const addCategory = (cat: ConstructeurCategorie) => {
|
||||
if (selectedKeys.value.has((cat.name || '').toLowerCase())) {
|
||||
return
|
||||
}
|
||||
emitSelection([...selected.value, cat])
|
||||
searchTerm.value = ''
|
||||
}
|
||||
|
||||
const removeCategory = (cat: ConstructeurCategorie) => {
|
||||
emitSelection(selected.value.filter(c => c !== cat && c.id !== cat.id))
|
||||
}
|
||||
|
||||
const createAndAdd = async () => {
|
||||
const created = await createCategory(searchTerm.value)
|
||||
if (created) {
|
||||
addCategory(created)
|
||||
}
|
||||
}
|
||||
|
||||
const onDocumentClick = (event: Event) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target && !target.closest('.constructeur-categorie-select')) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
|
||||
</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 -->
|
||||
<li
|
||||
v-for="group in navGroups"
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.id + '-mobile'"
|
||||
class="mt-1 border-t border-base-200 pt-2"
|
||||
>
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
<!-- Desktop: dropdown groups -->
|
||||
<li
|
||||
v-for="group in navGroups"
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.id + '-desktop'"
|
||||
class="relative"
|
||||
@mouseenter="setDropdown(group.id + '-desktop')"
|
||||
@@ -270,11 +270,9 @@ import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||
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 IconLucideLink from '~icons/lucide/link'
|
||||
import IconLucideSun from '~icons/lucide/sun'
|
||||
import IconLucideMoon from '~icons/lucide/moon'
|
||||
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||
@@ -296,55 +294,40 @@ interface NavGroup {
|
||||
icon?: Component
|
||||
activePaths: string[]
|
||||
children: NavLink[]
|
||||
requiresEdit?: boolean
|
||||
}
|
||||
|
||||
const simpleLinks: NavLink[] = [
|
||||
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
||||
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
||||
{ to: '/doc', label: 'Documentation', icon: IconLucideBookOpen },
|
||||
]
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'component',
|
||||
label: 'Composants',
|
||||
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',
|
||||
id: 'catalogues',
|
||||
label: 'Catalogues',
|
||||
icon: IconLucidePackage,
|
||||
activePaths: ['/product-category', '/product-catalog'],
|
||||
activePaths: ['/catalogues', '/component', '/piece', '/product'],
|
||||
children: [
|
||||
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
||||
{ to: '/product-category', label: 'Catégorie de produit' },
|
||||
{ to: '/catalogues/composants', label: 'Composants' },
|
||||
{ to: '/catalogues/pieces', label: 'Pièces' },
|
||||
{ to: '/catalogues/produits', label: 'Produits' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
label: 'Ressources liées',
|
||||
icon: IconLucideLink,
|
||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||
id: 'admin',
|
||||
label: 'Administration',
|
||||
icon: IconLucideSettings,
|
||||
activePaths: ['/sites', '/constructeurs', '/activity-log', '/admin', '/documents', '/comments', '/component-category', '/piece-category', '/product-category'],
|
||||
requiresEdit: true,
|
||||
children: [
|
||||
{ to: '/sites', label: 'Sites' },
|
||||
{ to: '/documents', label: 'Documents' },
|
||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||
{ to: '/comments', label: 'Commentaires' },
|
||||
{ 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 { activeProfile } = useProfileSession()
|
||||
const { isAdmin, canEdit } = usePermissions()
|
||||
|
||||
const visibleGroups = computed(() =>
|
||||
navGroups.filter(g => !g.requiresEdit || canEdit.value)
|
||||
)
|
||||
const { fetchUnresolvedCount } = useComments()
|
||||
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<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
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
|
||||
@@ -33,12 +33,11 @@
|
||||
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<input
|
||||
<CustomFieldNameInput
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
size="sm"
|
||||
/>
|
||||
<select v-model="field.type" class="select select-bordered select-sm">
|
||||
<option value="text">
|
||||
Texte
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<span class="label-text text-sm">{{ field.name }}</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatCustomFieldValue(field) }}
|
||||
{{ formatValueForDisplay(field) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,12 +50,11 @@
|
||||
<div class="flex-1 space-y-2">
|
||||
<!-- Definition fields -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<input
|
||||
:value="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
<CustomFieldNameInput
|
||||
:model-value="field.name"
|
||||
placeholder="Nom du champ"
|
||||
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
|
||||
size="sm"
|
||||
@update:model-value="(value: string) => handleDefinitionUpdate(field, 'name', value)"
|
||||
/>
|
||||
<select
|
||||
:value="field.type || 'text'"
|
||||
@@ -180,7 +179,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||
import { formatValueForDisplay } from '~/shared/utils/customFields'
|
||||
|
||||
defineProps<{
|
||||
customFields: any[]
|
||||
|
||||
@@ -1,40 +1,46 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-bold">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
||||
<button
|
||||
@click="$emit('toggle-edit')"
|
||||
class="btn btn-primary"
|
||||
:class="{ 'btn-outline': isEditMode }"
|
||||
>
|
||||
<IconLucideSquarePen
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-2xl font-bold">{{ title }}</h1>
|
||||
<div
|
||||
v-if="siteName"
|
||||
class="badge badge-outline font-semibold"
|
||||
:style="siteStyle"
|
||||
>
|
||||
{{ siteName }}
|
||||
</div>
|
||||
<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"
|
||||
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' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isEditMode"
|
||||
@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>
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm md:btn-md"
|
||||
title="Imprimer"
|
||||
@click="$emit('open-print')"
|
||||
>
|
||||
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
|
||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
Parc machines
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -43,11 +49,16 @@
|
||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
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
|
||||
description?: string
|
||||
siteName?: string
|
||||
siteColor?: string
|
||||
reference?: string
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
@@ -56,12 +67,12 @@ defineEmits<{
|
||||
'open-print': []
|
||||
}>()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
const siteStyle = computed(() => {
|
||||
if (!props.siteColor) return {}
|
||||
return {
|
||||
borderColor: props.siteColor + '60',
|
||||
backgroundColor: props.siteColor + '25',
|
||||
color: props.siteColor,
|
||||
}
|
||||
else {
|
||||
navigateTo('/machines')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<span v-if="isEditMode && files.length" class="badge badge-outline">
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
class="input input-bordered"
|
||||
@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 }}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -38,9 +38,9 @@
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</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' }}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="isEditMode || machineReference" class="form-control">
|
||||
<label class="label">
|
||||
@@ -54,9 +54,9 @@
|
||||
class="input input-bordered"
|
||||
@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 }}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
@@ -77,9 +77,9 @@
|
||||
@update:model-value="$emit('update:constructeur-links', $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">
|
||||
<span class="text-base-content/50">Non défini</span>
|
||||
</div>
|
||||
<p v-else-if="!isEditMode" class="text-sm font-medium text-base-content/50 py-1">
|
||||
Non défini
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,9 +152,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatCustomFieldValue(field) }}
|
||||
</div>
|
||||
<p class="text-sm font-medium text-base-content py-1">
|
||||
{{ formatValueForDisplay(field) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@ import { watch } from 'vue'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.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 type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<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
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
|
||||
@@ -29,7 +29,16 @@
|
||||
>
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<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>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@@ -133,7 +142,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
@@ -142,6 +151,9 @@ import {
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
const route = useRoute()
|
||||
const machineId = computed(() => route.params.id as string | undefined)
|
||||
|
||||
defineProps<{
|
||||
products: Array<{
|
||||
id?: string | null
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
<main
|
||||
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">
|
||||
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
|
||||
<p class="text-base text-base-content/70">
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
</header>
|
||||
<template v-if="!hideHeading">
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
|
||||
<p class="text-base text-base-content/70">
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<nav
|
||||
v-if="allowCategorySwitch"
|
||||
@@ -55,16 +57,6 @@
|
||||
/>
|
||||
</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 #cell-name="{ row }">
|
||||
@@ -76,19 +68,15 @@
|
||||
<span v-else class="text-base-content/50">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
||||
Liés
|
||||
</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)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -99,13 +87,6 @@
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<ConversionModal
|
||||
:open="conversionModalOpen"
|
||||
:model-type="conversionTarget"
|
||||
@close="closeConversionModal"
|
||||
@converted="onConverted"
|
||||
/>
|
||||
|
||||
<RelatedItemsModal
|
||||
:open="relatedModalOpen"
|
||||
:model-type="relatedType"
|
||||
@@ -119,7 +100,6 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import ConversionModal from '~/components/model-types/ConversionModal.vue'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||
import {
|
||||
@@ -133,7 +113,7 @@ import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import IconLucideSearch from '~icons/lucide/search'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
|
||||
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.'
|
||||
@@ -144,9 +124,11 @@ const props = withDefaults(
|
||||
heading: string
|
||||
description?: string
|
||||
allowCategorySwitch?: boolean
|
||||
hideHeading?: boolean
|
||||
}>(),
|
||||
{
|
||||
allowCategorySwitch: false,
|
||||
hideHeading: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -195,12 +177,11 @@ useHead(() => ({ title: headingText.value }))
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
||||
]
|
||||
|
||||
const showConvertButton = computed(() =>
|
||||
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
|
||||
)
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
const categories: Array<{ label: string, value: ModelCategory }> = [
|
||||
{ label: 'Composants', value: 'COMPONENT' },
|
||||
@@ -335,13 +316,6 @@ const resolveCategoryBasePath = (category: ModelCategory) => {
|
||||
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 category = item.category ?? selectedCategory.value
|
||||
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(
|
||||
() => searchInput.value,
|
||||
(value) => {
|
||||
|
||||
@@ -99,11 +99,7 @@
|
||||
v-else
|
||||
class="space-y-3 rounded-lg border border-base-300 p-4"
|
||||
>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="productStructure" />
|
||||
<PieceModelStructureEditor v-model="productStructure" hide-products />
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
@@ -194,20 +190,21 @@ const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
|
||||
})
|
||||
|
||||
const formulaBuilderCustomFields = computed(() => {
|
||||
let fields: any[] = []
|
||||
if (form.category === 'PIECE') {
|
||||
const fields = pieceStructure.value?.customFields
|
||||
return Array.isArray(fields) ? fields : []
|
||||
const raw = pieceStructure.value?.customFields
|
||||
fields = Array.isArray(raw) ? raw : []
|
||||
}
|
||||
if (form.category === 'COMPONENT') {
|
||||
const fields = componentStructure.value?.customFields
|
||||
return Array.isArray(fields) ? fields : []
|
||||
else if (form.category === 'COMPONENT') {
|
||||
const raw = componentStructure.value?.customFields
|
||||
fields = Array.isArray(raw) ? raw : []
|
||||
}
|
||||
return []
|
||||
return fields.filter((f: any) => !f.machineContextOnly)
|
||||
})
|
||||
|
||||
const extractFormulaFields = (formula: string | null | undefined): string[] => {
|
||||
if (!formula) return []
|
||||
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
|
||||
const matches = [...formula.matchAll(/\{([^}]+)\}/gu)]
|
||||
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ const preview = computed(() => {
|
||||
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
|
||||
}
|
||||
}
|
||||
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
|
||||
return props.modelValue.replace(/\{([^}]+)\}/gu, (_, name) => fieldMap.get(name) ?? '???')
|
||||
})
|
||||
|
||||
const insertField = (fieldName: string) => {
|
||||
|
||||
@@ -31,16 +31,28 @@
|
||||
:key="entry.id"
|
||||
class="px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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)"
|
||||
<div
|
||||
class="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-2 hover:bg-base-200"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||
Référence: {{ entry.reference }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<NuxtLink
|
||||
:to="itemDetailPath(entry)"
|
||||
class="font-medium hover:underline hover:text-primary transition-colors"
|
||||
@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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -57,14 +69,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import type { ModelCategory, ModelType } from '~/services/modelTypes'
|
||||
|
||||
type RelatedEntry = {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
machineCount: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -104,73 +115,37 @@ const modalSubtitle = computed(() => {
|
||||
return `${count} ${labels.plural} liés.`
|
||||
})
|
||||
|
||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
|
||||
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
|
||||
return { endpoint: '/products', filterKey: 'typeProduct' }
|
||||
}
|
||||
|
||||
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 itemDetailPath = (item: RelatedEntry) => {
|
||||
if (!props.modelType) return '#'
|
||||
const category = props.modelType.category
|
||||
if (category === 'COMPONENT') return `/component/${item.id}`
|
||||
if (category === 'PIECE') return `/piece/${item.id}`
|
||||
return `/product/${item.id}`
|
||||
}
|
||||
|
||||
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
|
||||
error.value = null
|
||||
items.value = []
|
||||
|
||||
try {
|
||||
const result = await get(`${endpoint}?${params.toString()}`)
|
||||
const result = await get(`/model_types/${modelType.id}/related-items`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger les éléments liés.'
|
||||
return
|
||||
}
|
||||
const collection = extractCollection(result.data)
|
||||
items.value = collection
|
||||
.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
|
||||
if (Array.isArray(result.data)) {
|
||||
items.value = result.data as RelatedEntry[]
|
||||
}
|
||||
error.value = humanizeError(raw)
|
||||
}
|
||||
catch {
|
||||
error.value = 'Impossible de charger les éléments liés.'
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenEdit = (entry: RelatedEntry) => {
|
||||
emit('open-edit', entry)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
<h3 class="card-title text-lg text-base-content">
|
||||
{{ site.name }}
|
||||
</h3>
|
||||
<div
|
||||
class="badge font-bold"
|
||||
<NuxtLink
|
||||
: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' } : {}"
|
||||
:class="!site.color ? 'badge-primary' : ''"
|
||||
>
|
||||
{{ machineCount }} machines
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
@@ -39,10 +40,10 @@
|
||||
</span>
|
||||
</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" />
|
||||
<span>{{ machineCount }} machine(s)</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-4">
|
||||
|
||||
@@ -17,15 +17,9 @@ import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
normalizeCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
@@ -40,7 +34,6 @@ import {
|
||||
import {
|
||||
hasAssignments,
|
||||
initializeStructureAssignments,
|
||||
isAssignmentNodeComplete,
|
||||
serializeStructureAssignments,
|
||||
} from '~/shared/utils/structureAssignmentHelpers'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
@@ -77,7 +70,6 @@ export function useComponentCreate() {
|
||||
loading: productsLoading,
|
||||
} = useProducts()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { syncLinks } = useConstructeurLinks()
|
||||
const { canEdit } = usePermissions()
|
||||
@@ -98,7 +90,8 @@ export function useComponentCreate() {
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
const lastSuggestedName = ref('')
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const createdComponentId = ref<string | null>(null)
|
||||
|
||||
const structureAssignments = ref<StructureAssignmentNode | null>(null)
|
||||
const selectedDocuments = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
@@ -148,26 +141,24 @@ export function useComponentCreate() {
|
||||
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(() =>
|
||||
hasAssignments(structureAssignments.value),
|
||||
)
|
||||
|
||||
const structureSelectionsComplete = computed(() => {
|
||||
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 structureSelectionsComplete = computed(() => true)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value
|
||||
@@ -225,7 +216,6 @@ export function useComponentCreate() {
|
||||
watch(selectedType, (type) => {
|
||||
if (!type) {
|
||||
clearCreationForm()
|
||||
customFieldInputs.value = []
|
||||
structureAssignments.value = null
|
||||
return
|
||||
}
|
||||
@@ -233,7 +223,8 @@ export function useComponentCreate() {
|
||||
creationForm.name = type.name
|
||||
}
|
||||
lastSuggestedName.value = creationForm.name
|
||||
customFieldInputs.value = normalizeCustomFieldInputs(selectedTypeStructure.value)
|
||||
// useCustomFieldInputs auto-refreshes via its watcher on definitions
|
||||
refreshCustomFieldInputs()
|
||||
structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value)
|
||||
})
|
||||
|
||||
@@ -305,11 +296,6 @@ export function useComponentCreate() {
|
||||
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
|
||||
? serializeStructureAssignments(structureAssignments.value)
|
||||
: null
|
||||
@@ -323,12 +309,11 @@ export function useComponentCreate() {
|
||||
const result = await createComposant(payload)
|
||||
if (result.success) {
|
||||
const createdComponent = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'composant',
|
||||
createdComponent.id,
|
||||
[createdComponent?.typeComposant?.structure?.customFields],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
createdComponentId.value = createdComponent.id
|
||||
const failedFields = await saveAllCustomFields()
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
|
||||
}
|
||||
if (selectedDocuments.value.length && result.data?.id) {
|
||||
uploadingDocuments.value = true
|
||||
const uploadResult = await uploadDocuments(
|
||||
@@ -413,6 +398,7 @@ export function useComponentCreate() {
|
||||
structureSelectionsComplete,
|
||||
canEdit,
|
||||
canSubmit,
|
||||
requiredCustomFieldsFilled,
|
||||
|
||||
// Functions
|
||||
typeOptionLabel,
|
||||
|
||||
@@ -6,14 +6,13 @@ import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useComponentHistory } from '~/composables/useComponentHistory'
|
||||
import { useEntityHistory } from '~/composables/useEntityHistory'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
@@ -29,12 +28,7 @@ import {
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
|
||||
import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils'
|
||||
|
||||
interface ComponentCatalogType extends ModelType {
|
||||
@@ -64,7 +58,6 @@ export function useComponentEdit(componentId: string) {
|
||||
const { products } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const {
|
||||
@@ -72,7 +65,7 @@ export function useComponentEdit(componentId: string) {
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useComponentHistory()
|
||||
} = useEntityHistory('composant')
|
||||
|
||||
const component = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
@@ -96,7 +89,6 @@ export function useComponentEdit(componentId: string) {
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||
@@ -207,18 +199,23 @@ export function useComponentEdit(componentId: string) {
|
||||
return structure ? normalizeStructureForEditor(structure) : null
|
||||
})
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: ComponentModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const structure = structureOverride ?? selectedTypeStructure.value ?? null
|
||||
const values = valuesOverride ?? component.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||
}
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
const {
|
||||
fields: customFieldInputs,
|
||||
requiredFilled: requiredCustomFieldsFilled,
|
||||
saveAll: saveAllCustomFields,
|
||||
refresh: refreshCustomFieldInputs,
|
||||
} = useCustomFieldInputs({
|
||||
definitions: computed(() => selectedTypeStructure.value?.customFields ?? []),
|
||||
values: computed(() => component.value?.customFieldValues ?? []),
|
||||
entityType: 'composant',
|
||||
entityId: computed(() => component.value?.id ?? null),
|
||||
context: 'standalone',
|
||||
onValueCreated: (newValue) => {
|
||||
if (component.value && Array.isArray(component.value.customFieldValues)) {
|
||||
component.value.customFieldValues.push(newValue)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value
|
||||
@@ -239,8 +236,7 @@ export function useComponentEdit(componentId: string) {
|
||||
component.value = result.data
|
||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
// The watcher on useCustomFieldInputs will auto-refresh when component.value changes
|
||||
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
}
|
||||
@@ -392,14 +388,10 @@ export function useComponentEdit(componentId: string) {
|
||||
const result = await updateComposant(component.value.id, payload)
|
||||
if (result.success && result.data) {
|
||||
const updatedComponent = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'composant',
|
||||
updatedComponent.id,
|
||||
[
|
||||
updatedComponent?.typeComposant?.structure?.customFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
const failedFields = await saveAllCustomFields()
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
|
||||
}
|
||||
|
||||
// Save slot edits
|
||||
const slotPromises: Promise<any>[] = []
|
||||
@@ -499,7 +491,7 @@ export function useComponentEdit(componentId: string) {
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
|
||||
// useCustomFieldInputs auto-refreshes via its watcher on definitions + values
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -565,6 +557,7 @@ export function useComponentEdit(componentId: string) {
|
||||
originalConstructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
customFieldInputs,
|
||||
requiredCustomFieldsFilled,
|
||||
historyFieldLabels,
|
||||
|
||||
// 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 { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Composant {
|
||||
id: string
|
||||
@@ -51,17 +51,6 @@ const total = ref(0)
|
||||
const loading = 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() {
|
||||
const { showSuccess } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
63
frontend/app/composables/useConstructeurCategories.ts
Normal file
63
frontend/app/composables/useConstructeurCategories.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface ConstructeurCategorie {
|
||||
'@id'?: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const categories = ref<ConstructeurCategorie[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const sortByName = (items: ConstructeurCategorie[]): ConstructeurCategorie[] =>
|
||||
[...items].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
|
||||
export function useConstructeurCategories() {
|
||||
const { get, post } = useApi()
|
||||
const { showError } = useToast()
|
||||
|
||||
const loadCategories = async (force = false): Promise<ConstructeurCategorie[]> => {
|
||||
if (loaded.value && !force) {
|
||||
return categories.value
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/constructeur_categories?itemsPerPage=1000')
|
||||
if (result.success) {
|
||||
categories.value = sortByName(extractCollection<ConstructeurCategorie>(result.data))
|
||||
loaded.value = true
|
||||
}
|
||||
return categories.value
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createCategory = async (name: string): Promise<ConstructeurCategorie | null> => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const existing = categories.value.find(c => c.name.toLowerCase() === trimmed.toLowerCase())
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const result = await post('/constructeur_categories', { name: trimmed })
|
||||
if (result.success && result.data && !Array.isArray(result.data)) {
|
||||
const created = result.data as ConstructeurCategorie
|
||||
categories.value = sortByName([...categories.value, created])
|
||||
return created
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return { categories, loading, loadCategories, createCategory }
|
||||
}
|
||||
@@ -1,13 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface ConstructeurTelephone {
|
||||
'@id'?: string
|
||||
id?: string
|
||||
numero: string
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
export interface ConstructeurCategorieRef {
|
||||
'@id'?: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Constructeur {
|
||||
'@id'?: string
|
||||
id: string
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
telephones?: ConstructeurTelephone[]
|
||||
categories?: ConstructeurCategorieRef[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface ConstructeurResult {
|
||||
@@ -16,6 +33,24 @@ interface ConstructeurResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ConstructeurPageOptions {
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
search?: string
|
||||
categoryId?: string
|
||||
orderField?: 'name' | 'email' | 'createdAt'
|
||||
orderDirection?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface ConstructeurPageResult {
|
||||
success: boolean
|
||||
items: Constructeur[]
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
currentPage: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
const constructeurs = ref<Constructeur[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
@@ -66,8 +101,10 @@ export function useConstructeurs() {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
const result = await get(`/constructeurs${query}`)
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '2000')
|
||||
if (search) params.set('search', search)
|
||||
const result = await get(`/constructeurs?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
constructeurs.value = uniqueConstructeurs(items)
|
||||
@@ -87,7 +124,38 @@ export function useConstructeurs() {
|
||||
return loadConstructeurs(search)
|
||||
}
|
||||
|
||||
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
const fetchConstructeursPage = async (opts: ConstructeurPageOptions = {}): Promise<ConstructeurPageResult> => {
|
||||
const page = Math.max(1, opts.page ?? 1)
|
||||
const itemsPerPage = Math.max(1, opts.itemsPerPage ?? 30)
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(page))
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
if (opts.search && opts.search.trim()) params.set('search', opts.search.trim())
|
||||
if (opts.categoryId) params.set('categories.id', opts.categoryId)
|
||||
if (opts.orderField) {
|
||||
params.set(`order[${opts.orderField}]`, opts.orderDirection ?? 'asc')
|
||||
}
|
||||
const result = await get(`/constructeurs?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: result.error }
|
||||
}
|
||||
const items = extractCollection<Constructeur>(result.data)
|
||||
const totalItems = extractTotal(result.data, items.length)
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
|
||||
upsertConstructeurs(items)
|
||||
return { success: true, items, totalItems, totalPages, currentPage: page }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors du chargement de la page fournisseurs:', error)
|
||||
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/constructeurs', data)
|
||||
@@ -161,7 +229,7 @@ export function useConstructeurs() {
|
||||
.filter((item): item is Constructeur => item !== null)
|
||||
}
|
||||
|
||||
const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
const updateConstructeur = async (id: string, data: Record<string, unknown>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/constructeurs/${id}`, data)
|
||||
@@ -210,6 +278,7 @@ export function useConstructeurs() {
|
||||
loading,
|
||||
loadConstructeurs,
|
||||
searchConstructeurs,
|
||||
fetchConstructeursPage,
|
||||
createConstructeur,
|
||||
updateConstructeur,
|
||||
deleteConstructeur,
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
41
frontend/app/composables/useCustomFieldNameSuggestions.ts
Normal file
41
frontend/app/composables/useCustomFieldNameSuggestions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const cache = ref<string[] | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
export function useCustomFieldNameSuggestions() {
|
||||
const api = useApi()
|
||||
|
||||
async function load(force = false): Promise<string[]> {
|
||||
if (cache.value && !force) return cache.value
|
||||
if (loading.value) return cache.value ?? []
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get<string[]>('/custom-fields/names')
|
||||
if (response.success && Array.isArray(response.data)) {
|
||||
cache.value = response.data
|
||||
}
|
||||
else {
|
||||
cache.value = cache.value ?? []
|
||||
if (response.error) {
|
||||
console.error('[useCustomFieldNameSuggestions] load failed:', response.error)
|
||||
}
|
||||
}
|
||||
return cache.value
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function invalidate(): void {
|
||||
cache.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: cache,
|
||||
loading,
|
||||
load,
|
||||
invalidate,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
@@ -58,13 +58,6 @@ const total = ref(0)
|
||||
const loading = 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() {
|
||||
const { get, patch, postFormData, delete: del } = useApi()
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import {
|
||||
listModelTypes,
|
||||
@@ -79,6 +80,7 @@ export function invalidateEntityTypeCache(category: ModelCategory) {
|
||||
export function useEntityTypes(config: EntityTypeConfig) {
|
||||
const { category, label } = config
|
||||
const { showSuccess, showError } = useToast()
|
||||
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
|
||||
const state = getOrCreateState(category)
|
||||
|
||||
const normalizeItem = (item: ModelType): EntityType => ({
|
||||
@@ -124,6 +126,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
})
|
||||
const normalized = normalizeItem(data)
|
||||
state.types.value.push(normalized)
|
||||
invalidateCustomFieldNames()
|
||||
showSuccess(`Type de ${label} "${data.name}" créé`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
@@ -150,6 +153,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
const normalized = normalizeItem(data)
|
||||
const index = state.types.value.findIndex((t) => t.id === id)
|
||||
if (index !== -1) state.types.value[index] = normalized
|
||||
invalidateCustomFieldNames()
|
||||
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
// --- Types ---
|
||||
@@ -88,6 +89,7 @@ const parseOptions = (optionsText: string): string[] =>
|
||||
export function useMachineCustomFieldDefs(deps: Deps) {
|
||||
const { apiCall } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
|
||||
|
||||
// --- State ---
|
||||
|
||||
@@ -294,6 +296,7 @@ export function useMachineCustomFieldDefs(deps: Deps) {
|
||||
}
|
||||
|
||||
showSuccess('Champs personnalisés sauvegardés avec succès')
|
||||
invalidateCustomFieldNames()
|
||||
await deps.onSaved()
|
||||
} catch {
|
||||
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import {
|
||||
shouldDisplayCustomField,
|
||||
normalizeExistingCustomFieldDefinitions,
|
||||
normalizeCustomFieldValueEntry,
|
||||
mergeCustomFieldValuesWithDefinitions,
|
||||
dedupeCustomFieldEntries,
|
||||
} from '~/shared/utils/customFieldUtils'
|
||||
mergeDefinitionsWithValues,
|
||||
filterByContext,
|
||||
hasDisplayableValue,
|
||||
type CustomFieldInput,
|
||||
} from '~/shared/utils/customFields'
|
||||
import {
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
@@ -53,56 +51,23 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
const visibleMachineCustomFields = computed(() => {
|
||||
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
|
||||
if (isEditMode.value) return fields
|
||||
return fields.filter((field) => shouldDisplayCustomField(field))
|
||||
return fields.filter((field) => hasDisplayableValue(field as unknown as CustomFieldInput))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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[] => {
|
||||
return (piecesData || []).map((piece) => {
|
||||
const typePiece = (piece.typePiece as AnyRecord) || {}
|
||||
|
||||
const normalizeStructureDefs = (structure: unknown) =>
|
||||
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
||||
|
||||
const normalizedStructureDefs = [
|
||||
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)),
|
||||
const customFields = filterByContext(
|
||||
mergeDefinitionsWithValues(
|
||||
typePiece.customFields ?? (piece.typePiece as AnyRecord)?.customFields ?? [],
|
||||
piece.customFieldValues ?? [],
|
||||
),
|
||||
'standalone',
|
||||
)
|
||||
|
||||
const constructeurIds = uniqueConstructeurIds(
|
||||
@@ -159,43 +124,16 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
}
|
||||
|
||||
const transformComponentCustomFields = (componentsData: AnyRecord[]): AnyRecord[] => {
|
||||
const normalizeStructureDefs = (structure: unknown) =>
|
||||
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
|
||||
|
||||
return (componentsData || []).map((component) => {
|
||||
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 valueEntries = [
|
||||
...(Array.isArray(component.customFieldValues) ? component.customFieldValues : []),
|
||||
...(Array.isArray(component.customFields)
|
||||
? (component.customFields as AnyRecord[])
|
||||
.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)),
|
||||
const customFields = filterByContext(
|
||||
mergeDefinitionsWithValues(
|
||||
type.customFields ?? [],
|
||||
component.customFieldValues ?? actualComponent?.customFieldValues ?? [],
|
||||
),
|
||||
'standalone',
|
||||
)
|
||||
|
||||
const piecesTransformed = component.pieces
|
||||
@@ -271,21 +209,11 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
machineCustomFields.value = []
|
||||
return
|
||||
}
|
||||
const valueEntries = [
|
||||
...(Array.isArray(machine.value.customFieldValues) ? machine.value.customFieldValues : []),
|
||||
...(Array.isArray(machine.value.customFields)
|
||||
? (machine.value.customFields as AnyRecord[])
|
||||
.map(normalizeCustomFieldValueEntry)
|
||||
.filter((e) => e !== null)
|
||||
: []),
|
||||
]
|
||||
const merged = dedupeCustomFieldEntries(
|
||||
mergeCustomFieldValuesWithDefinitions(
|
||||
valueEntries,
|
||||
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
|
||||
),
|
||||
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
|
||||
machineCustomFields.value = merged
|
||||
const merged = mergeDefinitionsWithValues(
|
||||
machine.value?.customFields ?? [],
|
||||
machine.value?.customFieldValues ?? [],
|
||||
)
|
||||
machineCustomFields.value = merged.map(f => ({ ...f, readOnly: false }))
|
||||
}
|
||||
|
||||
const setMachineCustomFieldValue = (field: AnyRecord, value: unknown) => {
|
||||
@@ -302,7 +230,8 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
const updateMachineCustomField = async (field: AnyRecord) => {
|
||||
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é'
|
||||
|
||||
try {
|
||||
@@ -467,7 +396,8 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
)
|
||||
|
||||
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 {
|
||||
if (customFieldValueId) {
|
||||
|
||||
@@ -119,7 +119,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
if (!machineName.value.trim()) return false
|
||||
return true
|
||||
})
|
||||
const debug = ref(false)
|
||||
|
||||
const componentsCollapsed = ref(true)
|
||||
const collapseToggleToken = ref(0)
|
||||
@@ -227,22 +226,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
const componentTypeOptions = computed(() => componentTypes.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
|
||||
const initMachineFields = () => {
|
||||
if (machine.value) {
|
||||
@@ -306,7 +289,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
// UI methods
|
||||
const toggleEditMode = () => {
|
||||
isEditMode.value = !isEditMode.value
|
||||
debug.value = !debug.value
|
||||
if (isEditMode.value && !machineDocumentsLoaded.value) {
|
||||
refreshMachineDocuments()
|
||||
}
|
||||
@@ -432,12 +414,6 @@ export function useMachineDetailData(machineId: string) {
|
||||
await productsPromise
|
||||
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) {
|
||||
components.value = transformComponentCustomFields(machinePayload.components || [])
|
||||
pieces.value = transformCustomFields(machinePayload.pieces || [])
|
||||
@@ -447,6 +423,8 @@ export function useMachineDetailData(machineId: string) {
|
||||
}
|
||||
|
||||
if (machine.value) {
|
||||
machine.value.componentLinks = machineComponentLinks.value
|
||||
machine.value.pieceLinks = machinePieceLinks.value
|
||||
machine.value.productLinks = machineProductLinks.value
|
||||
}
|
||||
|
||||
@@ -496,11 +474,11 @@ export function useMachineDetailData(machineId: string) {
|
||||
// UI state
|
||||
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
||||
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
||||
isEditMode, debug,
|
||||
isEditMode,
|
||||
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
||||
|
||||
// Computed
|
||||
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap,
|
||||
componentTypeOptions, pieceTypeOptions,
|
||||
productInventory, productById, flattenedComponents, machinePieces,
|
||||
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from '#imports'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { usePieceHistory } from '~/composables/usePieceHistory'
|
||||
import { useEntityHistory } from '~/composables/useEntityHistory'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
@@ -21,17 +20,11 @@ import {
|
||||
buildProductRequirementDescriptions,
|
||||
buildProductRequirementEntries,
|
||||
resizeProductSelections,
|
||||
areProductSelectionsFilled,
|
||||
applyProductSelection,
|
||||
collectNormalizedProductIds,
|
||||
} from '~/shared/utils/pieceProductSelectionUtils'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
structure: PieceModelStructure | null
|
||||
@@ -44,7 +37,6 @@ export function usePieceEdit(pieceId: string) {
|
||||
const { get } = useApi()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { updatePiece } = usePieces()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
@@ -54,7 +46,7 @@ export function usePieceEdit(pieceId: string) {
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = usePieceHistory()
|
||||
} = useEntityHistory('piece')
|
||||
|
||||
const piece = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
@@ -90,19 +82,29 @@ export function usePieceEdit(pieceId: string) {
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
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>(() =>
|
||||
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
|
||||
pieceTypeDetails.value?.structure ?? null,
|
||||
)
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: PieceModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const structure = structureOverride ?? resolvedStructure.value ?? null
|
||||
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||
}
|
||||
const {
|
||||
fields: customFieldInputs,
|
||||
requiredFilled: requiredCustomFieldsFilled,
|
||||
saveAll: saveAllCustomFields,
|
||||
refresh: refreshCustomFieldInputs,
|
||||
} = useCustomFieldInputs({
|
||||
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) => {
|
||||
if (!doc || !canPreviewDocument(doc)) {
|
||||
@@ -196,13 +198,7 @@ export function usePieceEdit(pieceId: string) {
|
||||
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
|
||||
)
|
||||
|
||||
const productSelectionsFilled = computed(() =>
|
||||
areProductSelectionsFilled(
|
||||
requiresProductSelection.value,
|
||||
productRequirementEntries.value,
|
||||
productSelections.value,
|
||||
),
|
||||
)
|
||||
const productSelectionsFilled = computed(() => true)
|
||||
|
||||
const setProductSelection = (index: number, value: string | null) => {
|
||||
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
||||
@@ -221,10 +217,6 @@ export function usePieceEdit(pieceId: string) {
|
||||
pendingProductIds = []
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(
|
||||
canEdit.value
|
||||
@@ -247,9 +239,7 @@ export function usePieceEdit(pieceId: string) {
|
||||
piece.value = result.data
|
||||
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
|
||||
// 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)
|
||||
// The watcher on useCustomFieldInputs will auto-refresh when piece.value changes
|
||||
|
||||
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
||||
loadPieceTypeDetailsFromCache(result.data)
|
||||
@@ -275,14 +265,14 @@ export function usePieceEdit(pieceId: string) {
|
||||
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
|
||||
if (cachedType) {
|
||||
pieceTypeDetails.value = cachedType
|
||||
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||
// useCustomFieldInputs auto-refreshes via its watcher on resolvedStructure
|
||||
return
|
||||
}
|
||||
// Fallback: fetch if not in cache (edge case)
|
||||
getModelType(typeId).then((type) => {
|
||||
if (type && typeof type === 'object') {
|
||||
pieceTypeDetails.value = type
|
||||
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||
// useCustomFieldInputs auto-refreshes via its watcher on resolvedStructure
|
||||
}
|
||||
}).catch(() => {
|
||||
pieceTypeDetails.value = null
|
||||
@@ -336,29 +326,21 @@ export function usePieceEdit(pieceId: string) {
|
||||
pendingProductIds = []
|
||||
}
|
||||
|
||||
// After setting selectedTypeId, read selectedType.value (now updated) instead of
|
||||
// the stale destructured currentType which was captured before the ID change.
|
||||
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
|
||||
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
|
||||
// useCustomFieldInputs auto-refreshes via its watcher on definitions + values
|
||||
|
||||
initialized = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(selectedType, (currentType) => {
|
||||
if (!piece.value || !currentType) {
|
||||
return
|
||||
}
|
||||
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
||||
})
|
||||
// useCustomFieldInputs auto-refreshes when selectedType changes (via resolvedStructure)
|
||||
|
||||
watch(resolvedStructure, (currentStructure) => {
|
||||
watch(resolvedStructure, () => {
|
||||
if (!piece.value) {
|
||||
return
|
||||
}
|
||||
ensureProductSelections(structureProducts.value.length)
|
||||
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
|
||||
// useCustomFieldInputs auto-refreshes via its watcher on definitions
|
||||
})
|
||||
|
||||
const submitEdition = async () => {
|
||||
@@ -366,11 +348,6 @@ export function usePieceEdit(pieceId: string) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!productSelectionsFilled.value) {
|
||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||
return
|
||||
}
|
||||
|
||||
const rawPrice = typeof editionForm.prix === 'string'
|
||||
? editionForm.prix.trim()
|
||||
: editionForm.prix === null || editionForm.prix === undefined
|
||||
@@ -407,15 +384,10 @@ export function usePieceEdit(pieceId: string) {
|
||||
try {
|
||||
const result = await updatePiece(piece.value.id, payload)
|
||||
if (result.success && result.data) {
|
||||
const updatedPiece = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'piece',
|
||||
updatedPiece.id,
|
||||
[
|
||||
updatedPiece?.typePiece?.structure?.customFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
const failedFields = await saveAllCustomFields()
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
|
||||
}
|
||||
await syncLinks('piece', piece.value.id, originalConstructeurLinks.value, constructeurLinks.value)
|
||||
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
|
||||
toast.showSuccess('Pièce mise à jour avec succès.')
|
||||
@@ -452,6 +424,7 @@ export function usePieceEdit(pieceId: string) {
|
||||
constructeurIdsFromForm,
|
||||
productSelections,
|
||||
customFieldInputs,
|
||||
requiredCustomFieldsFilled,
|
||||
canEdit,
|
||||
|
||||
// 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 { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Piece {
|
||||
id: string
|
||||
@@ -53,17 +53,6 @@ const total = ref(0)
|
||||
const loading = 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() {
|
||||
const { showSuccess } = useToast()
|
||||
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 { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
@@ -66,17 +66,6 @@ const replaceInCache = (item: Product): boolean => {
|
||||
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() {
|
||||
const { showError } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Toast {
|
||||
message: string
|
||||
type: ToastType
|
||||
visible: boolean
|
||||
duration: number
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([])
|
||||
@@ -32,6 +33,7 @@ export function useToast() {
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
duration,
|
||||
}
|
||||
|
||||
if (toasts.value.length >= MAX_TOASTS) {
|
||||
@@ -40,10 +42,11 @@ export function useToast() {
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
// Auto-remove after duration
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
@@ -52,7 +55,7 @@ export function useToast() {
|
||||
return showToast(message, 'success', duration)
|
||||
}
|
||||
|
||||
const showError = (message: string, duration = 5000): number => {
|
||||
const showError = (message: string, duration = 8000): number => {
|
||||
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="product">Produit</option>
|
||||
<option value="composant">Composant</option>
|
||||
<option value="machine">Machine</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -89,13 +90,16 @@
|
||||
|
||||
<template #cell-entity="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.action !== 'delete'"
|
||||
v-if="row.action !== 'delete' && entityEditLink(row) !== '#'"
|
||||
:to="entityEditLink(row)"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ row.entityName || 'Sans nom' }}
|
||||
</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' }}
|
||||
</span>
|
||||
<span
|
||||
@@ -195,19 +199,23 @@ const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
piece: 'Pièce',
|
||||
product: 'Produit',
|
||||
composant: 'Composant',
|
||||
machine: 'Machine',
|
||||
document: 'Document',
|
||||
model_type: 'Modèle',
|
||||
}
|
||||
|
||||
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
|
||||
|
||||
const ENTITY_EDIT_ROUTES: Record<string, string> = {
|
||||
piece: '/pieces',
|
||||
const ENTITY_ROUTES: Record<string, string> = {
|
||||
piece: '/piece',
|
||||
product: '/product',
|
||||
composant: '/component',
|
||||
machine: '/machine',
|
||||
}
|
||||
|
||||
const entityEditLink = (entry: ActivityLogEntry) => {
|
||||
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
|
||||
return base ? `${base}/${entry.entityId}/edit` : '#'
|
||||
const base = ENTITY_ROUTES[entry.entityType] ?? ''
|
||||
return base ? `${base}/${entry.entityId}` : '#'
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -159,6 +159,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
await updateModelType(id, enrichedPayload)
|
||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
await loadComponentTypes({ force: true })
|
||||
await loadCategory()
|
||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -183,6 +184,7 @@ const handleSyncConfirm = async () => {
|
||||
confirmTypeChanges: !!hasModifications,
|
||||
})
|
||||
await loadComponentTypes({ force: true })
|
||||
await loadCategory()
|
||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
<EmptyState
|
||||
v-else-if="!component"
|
||||
title="Composant introuvable"
|
||||
description="Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé."
|
||||
action-label="Retour au catalogue"
|
||||
action-to="/catalogues/composants"
|
||||
/>
|
||||
|
||||
<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">
|
||||
@@ -39,383 +33,435 @@
|
||||
:subtitle="isEditMode ? 'Ajustez les informations du composant et ses champs personnalisés.' : undefined"
|
||||
:is-edit-mode="isEditMode"
|
||||
:can-edit="canEdit"
|
||||
back-link="/component-catalog"
|
||||
back-link="/catalogues/composants"
|
||||
@toggle-edit="isEditMode = !isEditMode"
|
||||
/>
|
||||
|
||||
<!-- Catégorie (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">Catégorie de composant</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<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>
|
||||
</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))"
|
||||
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections composant">
|
||||
<template #tab-general>
|
||||
<div class="space-y-6">
|
||||
<!-- Catégorie (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">Catégorie de composant</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<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>
|
||||
</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>
|
||||
</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 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>
|
||||
<template v-if="isEditMode">
|
||||
<ProductSelect
|
||||
:model-value="slot.selectedProductId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-product-id="slot.typeProductId"
|
||||
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
</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'">
|
||||
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
||||
<template v-else>{{ slot.selectedProductName }}</template>
|
||||
<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>
|
||||
<template v-if="isEditMode">
|
||||
<ProductSelect
|
||||
:model-value="slot.selectedProductId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-product-id="slot.typeProductId"
|
||||
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
</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.selectedProductName }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<template v-if="isEditMode">
|
||||
<ComposantSelect
|
||||
:model-value="slot.selectedComponentId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-composant-id="slot.typeComposantId"
|
||||
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
</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'">
|
||||
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
||||
<template v-else>{{ slot.selectedComponentName }}</template>
|
||||
</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 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>
|
||||
<template v-if="isEditMode">
|
||||
<ComposantSelect
|
||||
:model-value="slot.selectedComponentId"
|
||||
:disabled="!canEdit || saving"
|
||||
:type-composant-id="slot.typeComposantId"
|
||||
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
</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.selectedComponentName }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<div
|
||||
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>
|
||||
<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 composant.' : 'Documents associés à ce composant.' }}
|
||||
</p>
|
||||
<template #tab-documents>
|
||||
<!-- Documents -->
|
||||
<div
|
||||
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>
|
||||
<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 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>
|
||||
<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"
|
||||
</template>
|
||||
|
||||
<template #tab-custom-fields>
|
||||
<!-- 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" />
|
||||
<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>
|
||||
<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>
|
||||
</EntityTabs>
|
||||
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
|
||||
<!-- Save buttons (edit mode only) -->
|
||||
<!-- Save/Cancel buttons (outside tabs) -->
|
||||
<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">
|
||||
Annuler
|
||||
@@ -425,16 +471,9 @@
|
||||
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>
|
||||
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -456,6 +495,12 @@ const { getConstructeurById } = useConstructeurs()
|
||||
const { updateDocument } = useDocuments()
|
||||
|
||||
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 {
|
||||
component,
|
||||
@@ -472,6 +517,7 @@ const {
|
||||
constructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
customFieldInputs,
|
||||
requiredCustomFieldsFilled,
|
||||
historyFieldLabels,
|
||||
canSubmit,
|
||||
componentTypeList,
|
||||
@@ -499,11 +545,14 @@ const {
|
||||
formatStructurePreview,
|
||||
} = useComponentEdit(String(route.params.id))
|
||||
|
||||
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||
|
||||
const submitEdition = async () => {
|
||||
await _submitEdition()
|
||||
if (!saving.value) {
|
||||
await fetchComponent()
|
||||
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) => {
|
||||
editingDocument.value = doc
|
||||
editModalVisible.value = true
|
||||
|
||||
@@ -1,212 +1,243 @@
|
||||
<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">
|
||||
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<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">
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<section 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="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>
|
||||
<SearchSelect
|
||||
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"
|
||||
<DetailHeader
|
||||
title="Nouveau composant"
|
||||
subtitle="Sélectionnez la catégorie cible puis complétez les informations du composant."
|
||||
:is-edit-mode="false"
|
||||
:can-edit="false"
|
||||
back-link="/catalogues/composants"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections composant">
|
||||
<template #tab-general>
|
||||
<div class="space-y-6">
|
||||
<!-- Catégorie -->
|
||||
<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>
|
||||
<SearchSelect
|
||||
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>
|
||||
|
||||
<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"
|
||||
:resolve-piece-label="resolvePieceLabel"
|
||||
:resolve-product-label="resolveProductLabel"
|
||||
:resolve-subcomponent-label="resolveSubcomponentLabel"
|
||||
/>
|
||||
<!-- Nom -->
|
||||
<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
|
||||
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.
|
||||
<!-- Description -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<span
|
||||
class="badge"
|
||||
:class="structureSelectionsComplete ? 'badge-success' : structureDataLoading ? 'badge-info' : 'badge-warning'"
|
||||
>
|
||||
{{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
<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.
|
||||
<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 à ce composant selon le squelette choisi.
|
||||
</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>
|
||||
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
||||
{{ 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…"
|
||||
<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.'"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||
Téléversement des documents en cours…
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</EntityTabs>
|
||||
|
||||
<!-- Save/Cancel buttons -->
|
||||
<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
|
||||
</NuxtLink>
|
||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
||||
@@ -214,17 +245,23 @@
|
||||
Créer le composant
|
||||
</button>
|
||||
</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>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
|
||||
const route = useRoute()
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
|
||||
const activeTab = ref('general')
|
||||
|
||||
const {
|
||||
selectedTypeId,
|
||||
submitting,
|
||||
@@ -259,8 +296,18 @@ const {
|
||||
resolveProductLabel,
|
||||
resolveSubcomponentLabel,
|
||||
submitCreation,
|
||||
requiredCustomFieldsFilled,
|
||||
} = 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
|
||||
watch(
|
||||
() => creationForm.constructeurIds,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
Fournisseurs
|
||||
</h1>
|
||||
<p class="text-sm text-gray-500">
|
||||
Gérez les fournisseurs et leurs coordonnées.
|
||||
Gérez les fournisseurs, leurs coordonnées et leurs catégories.
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
|
||||
@@ -19,35 +19,108 @@
|
||||
<div class="card-body space-y-4">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="filteredConstructeurs"
|
||||
:rows="pageItems"
|
||||
:loading="loading"
|
||||
:sort="currentSort"
|
||||
:show-counter="false"
|
||||
:pagination="paginationState"
|
||||
:show-counter="true"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun fournisseur trouvé."
|
||||
no-results-message="Aucun fournisseur trouvé."
|
||||
@sort="handleSort"
|
||||
@update:current-page="onPageChange"
|
||||
@update:per-page="onPerPageChange"
|
||||
>
|
||||
<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="searchTerm"
|
||||
type="search"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom, email ou téléphone"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-col sm:flex-row gap-3 w-full">
|
||||
<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="searchTerm"
|
||||
type="search"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom, email ou téléphone"
|
||||
@input="debouncedSearch"
|
||||
>
|
||||
</label>
|
||||
<label class="w-full sm:w-64">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Catégorie</span>
|
||||
<select
|
||||
v-model="selectedCategoryId"
|
||||
class="select select-bordered select-sm w-full mt-1"
|
||||
>
|
||||
<option value="">
|
||||
Toutes les catégories
|
||||
</option>
|
||||
<option v-for="cat in allCategories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-phone="{ row }">
|
||||
{{ formatPhoneDisplay(row.phone) }}
|
||||
<template #cell-telephones="{ row }">
|
||||
<div v-if="rowPhones(row).length" class="flex flex-col gap-0.5">
|
||||
<span v-for="(tel, idx) in rowPhones(row)" :key="idx" class="whitespace-nowrap text-sm">
|
||||
{{ formatPhoneDisplay(tel.numero) }}
|
||||
<span v-if="tel.label" class="text-xs text-base-content/50">({{ tel.label }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-base-content/30">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-categories="{ row }">
|
||||
<div v-if="row.categories && row.categories.length" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="cat in row.categories"
|
||||
:key="cat.id"
|
||||
class="badge badge-ghost badge-sm cursor-pointer hover:badge-primary transition-colors"
|
||||
@click="selectedCategoryId = cat.id"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-base-content/30">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||
</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 }">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
|
||||
@@ -63,7 +136,7 @@
|
||||
</div>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
||||
<div class="modal-box">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
|
||||
</h3>
|
||||
@@ -72,10 +145,53 @@
|
||||
<label class="label"><span class="label-text">Nom</span></label>
|
||||
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
|
||||
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
|
||||
|
||||
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
|
||||
|
||||
<div class="form-control">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="label-text">Téléphones</span>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="addTelephoneRow"
|
||||
>
|
||||
<IconLucidePlus class="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!form.telephones.length" class="text-sm text-base-content/50">
|
||||
Aucun téléphone.
|
||||
</p>
|
||||
<div v-for="(tel, idx) in form.telephones" :key="idx" class="flex items-end gap-2 mb-2">
|
||||
<div class="flex-1">
|
||||
<FieldPhone v-model="tel.numero" label="" :disabled="!canEdit" placeholder="Ex: 05 49 00 00 00" />
|
||||
</div>
|
||||
<input
|
||||
v-model="tel.label"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md w-40"
|
||||
placeholder="Libellé (optionnel)"
|
||||
:disabled="!canEdit"
|
||||
>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
aria-label="Supprimer ce téléphone"
|
||||
@click="removeTelephoneRow(idx)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Catégories</span></label>
|
||||
<ConstructeurCategorieSelect v-model="form.categories" :disabled="!canEdit" />
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeModal">
|
||||
Annuler
|
||||
@@ -91,33 +207,60 @@
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
import ConstructeurCategorieSelect from '~/components/form/ConstructeurCategorieSelect.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||
import { constructeurPhones } from '~/shared/constructeurUtils'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
interface TelephoneFormRow { '@id'?: string, numero: string, label: string }
|
||||
interface ConstructeurFormState {
|
||||
name: string
|
||||
email: string
|
||||
telephones: TelephoneFormRow[]
|
||||
categories: ConstructeurCategorie[]
|
||||
}
|
||||
|
||||
const api = useApi()
|
||||
const { canEdit } = usePermissions()
|
||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
||||
const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
|
||||
const { categories: allCategories, loadCategories } = useConstructeurCategories()
|
||||
const { showError } = useToast()
|
||||
|
||||
const pageItems = ref<typeof constructeurs.value>([])
|
||||
const totalItems = ref(0)
|
||||
const totalPages = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(30)
|
||||
const perPageOptions = [15, 30, 50, 100]
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'phone', label: 'Téléphone', sortable: true },
|
||||
{ key: 'telephones', label: 'Téléphones' },
|
||||
{ key: 'categories', label: 'Catégories' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
const searchTerm = ref('')
|
||||
const selectedCategoryId = ref('')
|
||||
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
||||
const sortDir = ref('asc')
|
||||
const stats = ref<Record<string, { composantCount?: number, pieceCount?: number, machineCount?: number }>>({})
|
||||
|
||||
const currentSort = computed(() => ({
|
||||
field: sortKey.value,
|
||||
@@ -129,40 +272,80 @@ const handleSort = (sort) => {
|
||||
sortDir.value = sort.direction
|
||||
}
|
||||
|
||||
const paginationState = computed(() => ({
|
||||
currentPage: currentPage.value,
|
||||
totalPages: totalPages.value,
|
||||
totalItems: totalItems.value,
|
||||
pageItems: pageItems.value.length,
|
||||
perPage: perPage.value,
|
||||
perPageOptions,
|
||||
}))
|
||||
|
||||
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
|
||||
|
||||
const loadPage = async () => {
|
||||
const orderField = SORTABLE_FIELDS.has(sortKey.value)
|
||||
? (sortKey.value as 'name' | 'email' | 'createdAt')
|
||||
: 'name'
|
||||
const result = await fetchConstructeursPage({
|
||||
page: currentPage.value,
|
||||
itemsPerPage: perPage.value,
|
||||
search: searchTerm.value,
|
||||
categoryId: selectedCategoryId.value || undefined,
|
||||
orderField,
|
||||
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
|
||||
})
|
||||
if (!result.success) {
|
||||
if (result.error) showError(result.error)
|
||||
pageItems.value = []
|
||||
totalItems.value = 0
|
||||
totalPages.value = 0
|
||||
return
|
||||
}
|
||||
pageItems.value = result.items
|
||||
totalItems.value = result.totalItems
|
||||
totalPages.value = result.totalPages
|
||||
if (currentPage.value > result.totalPages && result.totalPages > 0) {
|
||||
currentPage.value = result.totalPages
|
||||
}
|
||||
}
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingConstructeur = ref(null)
|
||||
const form = ref({ name: '', email: '', phone: '' })
|
||||
const editingConstructeur = ref<Record<string, any> | null>(null)
|
||||
const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], categories: [] })
|
||||
|
||||
const filteredConstructeurs = computed(() => {
|
||||
const key = sortKey.value
|
||||
const dir = sortDir.value === 'desc' ? -1 : 1
|
||||
const sorted = [...constructeurs.value].sort((a, b) => {
|
||||
if (key === 'createdAt') {
|
||||
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
|
||||
}
|
||||
return dir * (a[key] || '').localeCompare(b[key] || '')
|
||||
})
|
||||
if (!searchTerm.value) { return sorted }
|
||||
const term = searchTerm.value.toLowerCase()
|
||||
return sorted.filter(item =>
|
||||
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
|
||||
)
|
||||
const rowPhones = constructeurPhones
|
||||
|
||||
const debouncedSearch = debounce(() => {
|
||||
currentPage.value = 1
|
||||
loadPage()
|
||||
}, 300)
|
||||
|
||||
watch(selectedCategoryId, () => {
|
||||
currentPage.value = 1
|
||||
loadPage()
|
||||
})
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
await searchConstructeurs(searchTerm.value)
|
||||
}, 300)
|
||||
watch([sortKey, sortDir], () => {
|
||||
currentPage.value = 1
|
||||
loadPage()
|
||||
})
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
loadPage()
|
||||
}
|
||||
|
||||
const onPerPageChange = (value: number) => {
|
||||
perPage.value = value
|
||||
currentPage.value = 1
|
||||
loadPage()
|
||||
}
|
||||
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
const formatPhoneDisplay = (value) => {
|
||||
const formatted = formatPhone(value)
|
||||
if (formatted) {
|
||||
return formatted
|
||||
}
|
||||
return value || '—'
|
||||
}
|
||||
const formatPhoneDisplay = value => formatPhone(value) || value || '—'
|
||||
|
||||
function debounce(fn, delay) {
|
||||
let timeout
|
||||
@@ -173,7 +356,7 @@ function debounce(fn, delay) {
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = { name: '', email: '', phone: '' }
|
||||
form.value = { name: '', email: '', telephones: [], categories: [] }
|
||||
editingConstructeur.value = null
|
||||
}
|
||||
|
||||
@@ -187,7 +370,12 @@ const openEditModal = (constructeur) => {
|
||||
form.value = {
|
||||
name: constructeur.name,
|
||||
email: constructeur.email || '',
|
||||
phone: constructeur.phone || '',
|
||||
telephones: (constructeur.telephones || []).map(t => ({
|
||||
'@id': t['@id'],
|
||||
numero: t.numero || '',
|
||||
label: t.label || '',
|
||||
})),
|
||||
categories: (constructeur.categories || []).map(c => ({ ...c })),
|
||||
}
|
||||
modalOpen.value = true
|
||||
}
|
||||
@@ -197,8 +385,20 @@ const closeModal = () => {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const addTelephoneRow = () => {
|
||||
form.value.telephones.push({ numero: '', label: '' })
|
||||
}
|
||||
|
||||
const removeTelephoneRow = (idx) => {
|
||||
form.value.telephones.splice(idx, 1)
|
||||
}
|
||||
|
||||
const saveConstructeur = async () => {
|
||||
const trimmedName = form.value.name.trim()
|
||||
if (!trimmedName) {
|
||||
showError('Le nom est obligatoire.')
|
||||
return
|
||||
}
|
||||
const duplicate = constructeurs.value.find(
|
||||
c => c.name.toLowerCase() === trimmedName.toLowerCase()
|
||||
&& c.id !== editingConstructeur.value?.id,
|
||||
@@ -209,9 +409,24 @@ const saveConstructeur = async () => {
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
const payload = { ...form.value, name: trimmedName }
|
||||
if (!payload.email) { delete payload.email }
|
||||
if (!payload.phone) { delete payload.phone }
|
||||
const payload = {
|
||||
name: trimmedName,
|
||||
email: form.value.email?.trim() || null,
|
||||
telephones: form.value.telephones
|
||||
.filter(t => t.numero && t.numero.trim())
|
||||
.map((t) => {
|
||||
const entry: { numero: string, label: string | null, '@id'?: string } = {
|
||||
numero: t.numero.trim(),
|
||||
label: t.label?.trim() || null,
|
||||
}
|
||||
if (t['@id']) { entry['@id'] = t['@id'] }
|
||||
return entry
|
||||
}),
|
||||
categories: form.value.categories
|
||||
.map(c => c['@id'] || (c.id ? `/api/constructeur_categories/${c.id}` : null))
|
||||
.filter((iri): iri is string => Boolean(iri)),
|
||||
}
|
||||
|
||||
let result
|
||||
if (editingConstructeur.value) {
|
||||
result = await updateConstructeur(editingConstructeur.value.id, payload)
|
||||
@@ -222,7 +437,7 @@ const saveConstructeur = async () => {
|
||||
saving.value = false
|
||||
if (result.success) {
|
||||
closeModal()
|
||||
await searchConstructeurs(searchTerm.value)
|
||||
await loadPage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,8 +448,23 @@ const confirmDelete = async (constructeur) => {
|
||||
const result = await deleteConstructeur(constructeur.id)
|
||||
if (!result.success && result.error) {
|
||||
showError(result.error)
|
||||
return
|
||||
}
|
||||
if (result.success) {
|
||||
await loadPage()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadConstructeurs())
|
||||
const loadStats = async () => {
|
||||
const result = await api.get('/constructeurs/stats')
|
||||
if (result.success && result.data) {
|
||||
stats.value = result.data
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPage()
|
||||
loadCategories()
|
||||
loadStats()
|
||||
})
|
||||
</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 class="form-control md:w-64">
|
||||
<div class="form-control md:w-48">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
|
||||
</label>
|
||||
@@ -58,6 +58,24 @@
|
||||
</option>
|
||||
</select>
|
||||
</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>
|
||||
@@ -68,34 +86,22 @@
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="filteredSites.length === 0" class="text-center py-16">
|
||||
<div class="max-w-sm mx-auto">
|
||||
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
|
||||
<IconLucideFactory
|
||||
class="w-8 h-8 text-base-content/30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-base-content mb-1">
|
||||
Aucune machine trouvée
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/50 mb-6">
|
||||
Commencez par ajouter des sites et des machines.
|
||||
</p>
|
||||
<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>
|
||||
<EmptyState
|
||||
v-else-if="filteredSites.length === 0"
|
||||
:icon="IconLucideFactory"
|
||||
title="Aucune machine trouvée"
|
||||
description="Commencez par ajouter des sites et des machines."
|
||||
class="py-16"
|
||||
>
|
||||
<div v-if="canEdit" class="flex gap-2 justify-center">
|
||||
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
|
||||
Ajouter un site
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
|
||||
Ajouter une machine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyState>
|
||||
|
||||
<!-- Sites List -->
|
||||
<div v-else class="space-y-5">
|
||||
@@ -141,13 +147,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
class="badge font-bold"
|
||||
<NuxtLink
|
||||
: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' } : {}"
|
||||
:class="!site.color ? 'badge-primary' : ''"
|
||||
>
|
||||
{{ site.machines?.length || 0 }}
|
||||
</span>
|
||||
{{ site.machines?.length || 0 }} machine{{ (site.machines?.length || 0) > 1 ? 's' : '' }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
@click="toggleSiteCollapse(site.id)"
|
||||
@@ -288,6 +295,8 @@ const showAddSiteModal = ref(false)
|
||||
const showAddMachineModal = ref(false)
|
||||
const searchTerm = ref('')
|
||||
const selectedSiteFilter = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const collapsedSites = ref([])
|
||||
const preselectedSiteId = ref('')
|
||||
|
||||
@@ -338,6 +347,25 @@ const filteredSites = computed(() => {
|
||||
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
|
||||
if (searchTerm.value) {
|
||||
filtered = filtered.filter((site) => {
|
||||
|
||||
@@ -17,123 +17,129 @@
|
||||
|
||||
<!-- Header with actions -->
|
||||
<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"
|
||||
@toggle-edit="d.toggleEditMode"
|
||||
@open-print="d.openPrintModal"
|
||||
/>
|
||||
|
||||
<!-- Debug info -->
|
||||
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
|
||||
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
|
||||
<p>Components count: {{ d.components.value.length }}</p>
|
||||
<p>Pieces count: {{ d.pieces.value.length }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<PageHero
|
||||
:title="d.machine.value.name"
|
||||
:subtitle="d.machine.value.description"
|
||||
min-height="min-h-[20vh]"
|
||||
max-width="max-w-md"
|
||||
rounded
|
||||
>
|
||||
<div class="flex justify-center gap-4">
|
||||
<div
|
||||
v-if="d.machine.value.site?.name"
|
||||
class="badge badge-outline font-semibold"
|
||||
: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 } : {}"
|
||||
>
|
||||
{{ d.machine.value.site?.name }}
|
||||
<!-- Tabbed content -->
|
||||
<EntityTabs v-model="activeTab" :tabs="machineTabs" aria-label="Sections machine">
|
||||
<template #tab-general>
|
||||
<div class="space-y-8">
|
||||
<MachineInfoCard
|
||||
ref="machineInfoCardRef"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
:machine-name="d.machineName.value"
|
||||
:machine-reference="d.machineReference.value"
|
||||
:machine-site-id="d.machineSiteId.value"
|
||||
:machine-site-name="d.machine.value?.site?.name ?? ''"
|
||||
:sites="d.sites.value"
|
||||
:machine-constructeur-ids="d.machineConstructeurIds.value"
|
||||
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
|
||||
:has-machine-constructeur="d.hasMachineConstructeur.value"
|
||||
:constructeur-links="d.constructeurLinks.value"
|
||||
:visible-custom-fields="d.visibleMachineCustomFields.value"
|
||||
: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="() => { 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 v-if="d.machine.value.reference" class="badge badge-outline">
|
||||
{{ d.machine.value.reference }}
|
||||
</template>
|
||||
|
||||
<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>
|
||||
</PageHero>
|
||||
</template>
|
||||
|
||||
<!-- Machine Info Card -->
|
||||
<MachineInfoCard
|
||||
ref="machineInfoCardRef"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
:machine-name="d.machineName.value"
|
||||
:machine-reference="d.machineReference.value"
|
||||
:machine-site-id="d.machineSiteId.value"
|
||||
:machine-site-name="d.machine.value?.site?.name ?? ''"
|
||||
:sites="d.sites.value"
|
||||
:machine-constructeur-ids="d.machineConstructeurIds.value"
|
||||
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
|
||||
:has-machine-constructeur="d.hasMachineConstructeur.value"
|
||||
:constructeur-links="d.constructeurLinks.value"
|
||||
:visible-custom-fields="d.visibleMachineCustomFields.value"
|
||||
: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() }"
|
||||
/>
|
||||
<template #tab-documents>
|
||||
<MachineDocumentsCard
|
||||
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
|
||||
:documents="d.machineDocumentsList.value"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
:uploading="d.machineDocumentsUploading.value"
|
||||
:files="d.machineDocumentFiles.value"
|
||||
@update:files="d.machineDocumentFiles.value = $event"
|
||||
@files-added="d.handleMachineFilesAdded"
|
||||
@preview="d.openPreview"
|
||||
@download="d.downloadDocument"
|
||||
@remove="confirmRemoveDocument"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Documents -->
|
||||
<MachineDocumentsCard
|
||||
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
|
||||
:documents="d.machineDocumentsList.value"
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
:uploading="d.machineDocumentsUploading.value"
|
||||
:files="d.machineDocumentFiles.value"
|
||||
@update:files="d.machineDocumentFiles.value = $event"
|
||||
@files-added="d.handleMachineFilesAdded"
|
||||
@preview="d.openPreview"
|
||||
@download="d.downloadDocument"
|
||||
@remove="d.removeMachineDocument"
|
||||
/>
|
||||
|
||||
<!-- Produits associés -->
|
||||
<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="async (id) => { await d.removeProductLink(id); refreshVersions() }"
|
||||
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'product', typeId)"
|
||||
/>
|
||||
|
||||
<!-- Components Section -->
|
||||
<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"
|
||||
/>
|
||||
<template #tab-history>
|
||||
<div class="space-y-8">
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
<EntityVersionList
|
||||
ref="versionListRef"
|
||||
entity-type="machine"
|
||||
:entity-id="String(machineId)"
|
||||
:field-labels="historyFieldLabels"
|
||||
:refresh-key="versionRefreshKey"
|
||||
@restored="d.loadMachineData()"
|
||||
/>
|
||||
<CommentSection
|
||||
entity-type="machine"
|
||||
:entity-id="String(machineId)"
|
||||
:entity-name="d.machine.value?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EntityTabs>
|
||||
|
||||
<!-- Add Entity Modal -->
|
||||
<AddEntityToMachineModal
|
||||
@@ -164,50 +170,17 @@
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
|
||||
<IconLucideAlertTriangle class="w-8 h-8 text-base-content/30" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-base-content mb-1">Machine non trouvée</h3>
|
||||
<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>
|
||||
<button type="button" class="btn btn-primary" @click="$router.back()">
|
||||
Retour aux machines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
:icon="IconLucideAlertTriangle"
|
||||
title="Machine non trouvée"
|
||||
:description="`La machine avec l'ID « ${machineId} » n'existe pas ou a été supprimée.`"
|
||||
action-label="Retour aux machines"
|
||||
@action="$router.back()"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<MachinePrintSelectionModal
|
||||
@@ -223,13 +196,12 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMachineDetailData } from '~/composables/useMachineDetailData'
|
||||
import { useEntityHistory } from '~/composables/useEntityHistory'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import PageHero from '~/components/PageHero.vue'
|
||||
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
|
||||
import MachineDetailHeader from '~/components/machine/MachineDetailHeader.vue'
|
||||
import MachineInfoCard from '~/components/machine/MachineInfoCard.vue'
|
||||
@@ -251,9 +223,24 @@ if (!machineId) {
|
||||
}
|
||||
|
||||
const d = useMachineDetailData(machineId)
|
||||
const machineInfoCardRef = ref(null)
|
||||
const machineInfoCardRef = ref<{ saveFieldDefinitions?: () => Promise<void> } | null>(null)
|
||||
const versionRefreshKey = ref(0)
|
||||
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 {
|
||||
history,
|
||||
@@ -321,25 +308,49 @@ const handleAddEntity = async (payload) => {
|
||||
refreshVersions()
|
||||
}
|
||||
|
||||
const handleFillEntity = (linkId, entityKind, modelTypeId) => {
|
||||
const handleFillEntity = (linkId: string, entityKind: string, modelTypeId: string) => {
|
||||
fillLinkId.value = linkId
|
||||
fillTypeId.value = modelTypeId
|
||||
addModalKind.value = entityKind
|
||||
addModalOpen.value = true
|
||||
}
|
||||
|
||||
const machineViewTitle = computed(() => {
|
||||
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
|
||||
})
|
||||
|
||||
const submitMachineEdition = async () => {
|
||||
if (machineInfoCardRef.value?.saveFieldDefinitions) {
|
||||
await machineInfoCardRef.value.saveFieldDefinitions()
|
||||
isSavingMachine.value = true
|
||||
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()
|
||||
}
|
||||
|
||||
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(() => {
|
||||
d.loadMachineData()
|
||||
d.loadInitialData()
|
||||
|
||||
@@ -5,114 +5,93 @@
|
||||
<h2 class="text-2xl font-bold">
|
||||
Parc Machines
|
||||
</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" />
|
||||
Ajouter une machine
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-sm mb-6">
|
||||
<div class="card-body">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Sites</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="filteredMachines"
|
||||
:loading="loading"
|
||||
:sort="currentSort"
|
||||
:show-counter="true"
|
||||
empty-message="Aucune machine trouvée."
|
||||
no-results-message="Aucune machine ne correspond à vos filtres."
|
||||
@sort="handleSort"
|
||||
>
|
||||
<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>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Rechercher par nom ou référence..."
|
||||
class="input input-bordered"
|
||||
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Site</span>
|
||||
<select v-model="selectedSiteId" class="select select-bordered select-sm mt-1">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ row.site.name }}
|
||||
</span>
|
||||
<span v-else class="text-base-content/30">—</span>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex justify-center items-center py-12">
|
||||
<span class="loading loading-spinner loading-lg" />
|
||||
</div>
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<div v-else-if="filteredMachines.length === 0" class="text-center py-12">
|
||||
<div class="max-w-md mx-auto">
|
||||
<IconLucideFactory class="w-16 h-16 mx-auto text-gray-400 mb-4" aria-hidden="true" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucune machine trouvée
|
||||
</h3>
|
||||
<p class="text-gray-500 mb-4">
|
||||
Commencez par ajouter votre première machine.
|
||||
</p>
|
||||
<NuxtLink to="/machines/new" class="btn btn-primary">
|
||||
Ajouter une machine
|
||||
</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>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button v-if="canEdit" class="btn btn-ghost btn-xs" @click="editMachine(row)">
|
||||
Modifier
|
||||
</button>
|
||||
<button v-if="canEdit" class="btn btn-ghost btn-xs text-error" @click="confirmDeleteMachine(row)">
|
||||
Supprimer
|
||||
</button>
|
||||
<NuxtLink :to="`/machine/${row.id}`" class="btn btn-primary btn-xs">
|
||||
Détails
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-if="machine.reference" class="flex items-center gap-2">
|
||||
<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>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,16 +99,16 @@
|
||||
</template>
|
||||
|
||||
<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 { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
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 { machines, loading, loadMachines, deleteMachine } = useMachines()
|
||||
@@ -138,34 +117,46 @@ const toast = useToast()
|
||||
|
||||
const urlState = useUrlState({
|
||||
q: { default: '', debounce: 300 },
|
||||
sites: { default: '' },
|
||||
site: { default: '' },
|
||||
from: { default: '' },
|
||||
to: { default: '' },
|
||||
})
|
||||
|
||||
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
|
||||
watch(urlState.sites, (val) => {
|
||||
selectedSites.clear()
|
||||
if (val) {
|
||||
for (const id of String(val).split(',')) {
|
||||
if (id) selectedSites.add(id)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
const sortKey = usePersistedValue('machines-sort', 'name')
|
||||
const sortDir = ref('asc')
|
||||
|
||||
// Sync selectedSites → URL
|
||||
watch(() => [...selectedSites], (ids) => {
|
||||
urlState.sites.value = ids.join(',')
|
||||
})
|
||||
const currentSort = computed(() => ({
|
||||
field: sortKey.value,
|
||||
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(() => {
|
||||
return machines.value.map((machine) => {
|
||||
const site = sites.value.find(s => s.id === machine.siteId)
|
||||
return {
|
||||
...machine,
|
||||
site: site || null,
|
||||
siteName: site?.name || '',
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -173,29 +164,44 @@ const enrichedMachines = computed(() => {
|
||||
const filteredMachines = computed(() => {
|
||||
let filtered = enrichedMachines.value
|
||||
|
||||
if (selectedSites.size > 0) {
|
||||
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
|
||||
if (selectedSiteId.value) {
|
||||
filtered = filtered.filter(m => m.siteId === selectedSiteId.value)
|
||||
}
|
||||
|
||||
if (searchQuery.value.trim()) {
|
||||
const term = searchQuery.value.trim().toLowerCase()
|
||||
filtered = filtered.filter(machine =>
|
||||
machine.name?.toLowerCase().includes(term)
|
||||
|| machine.reference?.toLowerCase().includes(term),
|
||||
filtered = filtered.filter(m =>
|
||||
m.name?.toLowerCase().includes(term)
|
||||
|| m.reference?.toLowerCase().includes(term),
|
||||
)
|
||||
}
|
||||
|
||||
filtered = [...filtered].sort((a, b) =>
|
||||
(a.name || '').localeCompare(b.name || '', 'fr')
|
||||
)
|
||||
if (dateFrom.value) {
|
||||
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
|
||||
})
|
||||
|
||||
const viewMachineDetails = (machine) => {
|
||||
navigateTo(`/machine/${machine.id}`)
|
||||
}
|
||||
|
||||
const editMachine = (machine) => {
|
||||
navigateTo(`/machine/${machine.id}?edit=true`)
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
await updateModelType(id, enrichedPayload)
|
||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
await loadPieceTypes({ force: true })
|
||||
await loadCategory()
|
||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -181,6 +182,7 @@ const handleSyncConfirm = async () => {
|
||||
confirmTypeChanges: !!hasModifications,
|
||||
})
|
||||
await loadPieceTypes({ force: true })
|
||||
await loadCategory()
|
||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
|
||||
@@ -18,19 +18,13 @@
|
||||
<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>
|
||||
<EmptyState
|
||||
v-else-if="!piece"
|
||||
title="Pièce introuvable"
|
||||
description="Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée."
|
||||
action-label="Retour au catalogue"
|
||||
action-to="/catalogues/pieces"
|
||||
/>
|
||||
|
||||
<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">
|
||||
@@ -39,331 +33,385 @@
|
||||
:subtitle="isEditMode ? 'Ajustez les informations de la pièce et ses champs personnalisés.' : undefined"
|
||||
:is-edit-mode="isEditMode"
|
||||
:can-edit="canEdit"
|
||||
back-link="/pieces-catalog"
|
||||
back-link="/catalogues/pieces"
|
||||
@toggle-edit="isEditMode = !isEditMode"
|
||||
/>
|
||||
|
||||
<!-- Catégorie (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">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"
|
||||
<EntityTabs
|
||||
v-model="activeTab"
|
||||
:tabs="entityTabs"
|
||||
aria-label="Sections de la pièce"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ piece.reference }}
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
<template #tab-general>
|
||||
<div class="space-y-6">
|
||||
<!-- Catégorie (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">Catégorie de pièce</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<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 pieceTypeList"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
>
|
||||
{{ type.name }}
|
||||
</option>
|
||||
</select>
|
||||
<NuxtLink
|
||||
v-if="selectedTypeId"
|
||||
:to="`/piece-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>
|
||||
</div>
|
||||
</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"
|
||||
/>
|
||||
<!-- 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
|
||||
>
|
||||
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||
{{ piece.name }}
|
||||
</p>
|
||||
</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 à 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"
|
||||
>
|
||||
<!-- Description (if value or edit mode) -->
|
||||
<div v-if="isEditMode || piece.description" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{ field.name }}</span>
|
||||
<span class="label-text">Description</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm bg-base-200 flex items-center">
|
||||
{{ field.value }}
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<div
|
||||
v-if="isEditMode || pieceDocuments.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>
|
||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||
<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>
|
||||
<template #tab-documents>
|
||||
<div class="space-y-6">
|
||||
<!-- Documents -->
|
||||
<div
|
||||
v-if="isEditMode || pieceDocuments.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>
|
||||
<h2 class="font-semibold text-base-content">Documents</h2>
|
||||
<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>
|
||||
<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"
|
||||
</template>
|
||||
|
||||
<template #tab-custom-fields>
|
||||
<div class="space-y-6">
|
||||
<!-- 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 à cette pièce.
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<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()"
|
||||
/>
|
||||
</EntityTabs>
|
||||
|
||||
<!-- Save buttons (edit mode only) -->
|
||||
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
@@ -375,16 +423,9 @@
|
||||
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>
|
||||
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -405,6 +446,11 @@ const { updateDocument } = useDocuments()
|
||||
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 {
|
||||
piece,
|
||||
loading,
|
||||
@@ -420,6 +466,7 @@ const {
|
||||
constructeurLinks,
|
||||
productSelections,
|
||||
customFieldInputs,
|
||||
requiredCustomFieldsFilled,
|
||||
pieceTypeList,
|
||||
selectedType,
|
||||
resolvedStructure,
|
||||
@@ -441,6 +488,16 @@ const {
|
||||
formatPieceStructurePreview,
|
||||
} = 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 () => {
|
||||
await _submitEdition()
|
||||
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>
|
||||
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<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">
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<section 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="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>
|
||||
<SearchSelect
|
||||
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"
|
||||
<DetailHeader
|
||||
title="Nouvelle pièce"
|
||||
subtitle="Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce."
|
||||
:is-edit-mode="false"
|
||||
:can-edit="false"
|
||||
back-link="/catalogues/pieces"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections de la pièce">
|
||||
<template #tab-general>
|
||||
<div class="space-y-6">
|
||||
<!-- Catégorie -->
|
||||
<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>
|
||||
<SearchSelect
|
||||
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
|
||||
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">
|
||||
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="Un produit est requis pour cette pièce."
|
||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||
<!-- Nom -->
|
||||
<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>
|
||||
|
||||
<!-- Description -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<template #tab-products>
|
||||
<div class="space-y-6">
|
||||
<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">
|
||||
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">
|
||||
<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" />
|
||||
</div>
|
||||
<EmptyState
|
||||
v-if="!structureProducts.length"
|
||||
title="Aucun produit requis"
|
||||
:description="selectedType ? 'Cette catégorie ne requiert pas de produit lié.' : 'Sélectionnez une catégorie pour voir les produits requis.'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 à cette pièce.
|
||||
<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 à 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>
|
||||
</div>
|
||||
<span v-if="selectedDocuments.length" class="badge badge-outline">
|
||||
{{ 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>
|
||||
|
||||
<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">
|
||||
<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
|
||||
</NuxtLink>
|
||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
||||
@@ -208,6 +240,9 @@
|
||||
Créer la pièce
|
||||
</button>
|
||||
</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>
|
||||
</section>
|
||||
</main>
|
||||
@@ -225,7 +260,6 @@ import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
@@ -239,16 +273,10 @@ import {
|
||||
buildProductRequirementDescriptions,
|
||||
buildProductRequirementEntries,
|
||||
resizeProductSelections,
|
||||
areProductSelectionsFilled,
|
||||
applyProductSelection,
|
||||
collectNormalizedProductIds,
|
||||
} from '~/shared/utils/pieceProductSelectionUtils'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
normalizeCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
structure: PieceModelStructure | null
|
||||
@@ -261,12 +289,12 @@ const router = useRouter()
|
||||
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
|
||||
const { createPiece } = usePieces()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { syncLinks } = useConstructeurLinks()
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const activeTab = ref('general')
|
||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||
const submitting = ref(false)
|
||||
@@ -281,7 +309,16 @@ const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const productSelections = ref<(string | null)[]>([])
|
||||
|
||||
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 uploadingDocuments = ref(false)
|
||||
|
||||
@@ -341,13 +378,7 @@ const productRequirementEntries = computed(() =>
|
||||
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
|
||||
)
|
||||
|
||||
const productSelectionsFilled = computed(() =>
|
||||
areProductSelectionsFilled(
|
||||
requiresProductSelection.value,
|
||||
productRequirementEntries.value,
|
||||
productSelections.value,
|
||||
),
|
||||
)
|
||||
const productSelectionsFilled = computed(() => true)
|
||||
|
||||
const setProductSelection = (index: number, value: string | null) => {
|
||||
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
||||
@@ -360,21 +391,17 @@ watch(structureProducts, (products) => {
|
||||
watch(selectedType, (type) => {
|
||||
if (!type) {
|
||||
clearCreationForm()
|
||||
customFieldInputs.value = []
|
||||
cfDefinitions.value = []
|
||||
return
|
||||
}
|
||||
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
|
||||
creationForm.name = type.name
|
||||
}
|
||||
lastSuggestedName.value = creationForm.name
|
||||
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
||||
cfDefinitions.value = type.structure?.customFields ?? []
|
||||
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(
|
||||
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 = () => {
|
||||
creationForm.name = ''
|
||||
creationForm.description = ''
|
||||
@@ -403,11 +437,6 @@ const submitCreation = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!productSelectionsFilled.value) {
|
||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||
return
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: creationForm.name.trim(),
|
||||
typePieceId: selectedType.value.id,
|
||||
@@ -450,14 +479,11 @@ const submitCreation = async () => {
|
||||
const result = await createPiece(payload)
|
||||
if (result.success && result.data) {
|
||||
const createdPiece = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'piece',
|
||||
createdPiece.id,
|
||||
[
|
||||
createdPiece?.typePiece?.structure?.customFields,
|
||||
],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
createdEntityId.value = createdPiece.id
|
||||
const failedFields = await saveAllCustomFields()
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Pièce créée, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||
}
|
||||
// Sync constructeur links after creation
|
||||
if (constructeurLinks.value.length) {
|
||||
await syncLinks('piece', createdPiece.id, [], constructeurLinks.value)
|
||||
@@ -515,5 +541,4 @@ watch(
|
||||
onMounted(async () => {
|
||||
await loadPieceTypes()
|
||||
})
|
||||
|
||||
</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"
|
||||
:is-edit-mode="isEditMode"
|
||||
:can-edit="canEdit"
|
||||
back-link="/product-catalog"
|
||||
back-link="/catalogues/produits"
|
||||
@toggle-edit="isEditMode = !isEditMode"
|
||||
/>
|
||||
|
||||
<!-- Catégorie (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">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"
|
||||
<EntityTabs
|
||||
v-model="activeTab"
|
||||
:tabs="entityTabs"
|
||||
aria-label="Sections du produit"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ product.reference }}
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<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 }}
|
||||
<template #tab-general>
|
||||
<div class="space-y-6">
|
||||
<!-- Catégorie (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">Catégorie de produit</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md bg-base-200 flex-1"
|
||||
disabled
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="product?.typeProduct?.id"
|
||||
:to="`/product-category/${product.typeProduct.id}/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">
|
||||
{{ product?.typeProduct?.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 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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<div
|
||||
v-if="isEditMode || productDocuments.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>
|
||||
<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.' }}
|
||||
<template #tab-custom-fields>
|
||||
<div class="space-y-6">
|
||||
<!-- 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" />
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<EntityHistorySection
|
||||
:entries="history"
|
||||
:loading="historyLoading"
|
||||
:error="historyError"
|
||||
:field-labels="historyFieldLabels"
|
||||
/>
|
||||
<template #tab-history>
|
||||
<div class="space-y-6">
|
||||
<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()"
|
||||
/>
|
||||
|
||||
<!-- 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) -->
|
||||
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
@@ -283,19 +341,9 @@
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</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.
|
||||
</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>
|
||||
@@ -304,18 +352,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 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 { useEntityHistory } from '~/composables/useEntityHistory'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
@@ -323,19 +370,12 @@ 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'
|
||||
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { getProduct, updateProduct } = useProducts()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const {
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments: uploadProductDocuments,
|
||||
@@ -349,9 +389,10 @@ const {
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useProductHistory()
|
||||
} = useEntityHistory('product')
|
||||
|
||||
const isEditMode = ref(false)
|
||||
const versionRefreshKey = ref(0)
|
||||
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
@@ -359,7 +400,20 @@ const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const product = ref<any | null>(null)
|
||||
const productType = ref<any | 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 saving = ref(false)
|
||||
const selectedFiles = ref<File[]>([])
|
||||
@@ -385,7 +439,8 @@ const refreshCustomFieldInputs = (
|
||||
) => {
|
||||
const nextStructure = structureOverride ?? structure.value ?? 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({
|
||||
@@ -395,9 +450,7 @@ const editionForm = reactive({
|
||||
supplierPrice: '' as string,
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
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) => {
|
||||
if (!doc || !canPreviewDocument(doc)) return
|
||||
previewDocument.value = doc
|
||||
@@ -595,12 +660,7 @@ const submitEdition = async () => {
|
||||
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 },
|
||||
)
|
||||
const failedFields = await saveAllCustomFields()
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||
return
|
||||
@@ -610,6 +670,7 @@ const submitEdition = async () => {
|
||||
toast.showSuccess('Produit mis à jour avec succès')
|
||||
await loadProduct()
|
||||
isEditMode.value = false
|
||||
versionRefreshKey.value++
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||
|
||||
@@ -1,158 +1,178 @@
|
||||
<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">
|
||||
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<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">
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<section 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="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>
|
||||
<SearchSelect
|
||||
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"
|
||||
<DetailHeader
|
||||
title="Nouveau produit"
|
||||
subtitle="Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue."
|
||||
:is-edit-mode="false"
|
||||
:can-edit="false"
|
||||
back-link="/catalogues/produits"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections du produit">
|
||||
<template #tab-general>
|
||||
<div class="space-y-6">
|
||||
<!-- Catégorie -->
|
||||
<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>
|
||||
<SearchSelect
|
||||
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 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.' }}
|
||||
<!-- Nom -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 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 produit catalogue.
|
||||
</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 pour ce produit (fiches techniques, notices, etc.).
|
||||
<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 à ce produit catalogue.
|
||||
</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>
|
||||
<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…"
|
||||
<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.'"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
|
||||
Téléversement des documents en cours…
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</EntityTabs>
|
||||
|
||||
<!-- Save/Cancel buttons -->
|
||||
<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
|
||||
</NuxtLink>
|
||||
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
|
||||
@@ -160,7 +180,7 @@
|
||||
Créer le produit
|
||||
</button>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -177,7 +197,6 @@ import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
@@ -186,10 +205,7 @@ import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import type { ProductModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
normalizeCustomFieldInputs as normalizeCustomFieldInputsFromUtils,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
|
||||
|
||||
interface ProductCatalogType extends ModelType {
|
||||
structure: ProductModelStructure | null
|
||||
@@ -202,12 +218,12 @@ const router = useRouter()
|
||||
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
|
||||
const { createProduct } = useProducts()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { canEdit } = usePermissions()
|
||||
const { syncLinks } = useConstructeurLinks()
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
|
||||
const activeTab = ref('general')
|
||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||
const submitting = ref(false)
|
||||
@@ -221,7 +237,16 @@ const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const selectedDocuments = ref<File[]>([])
|
||||
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[]>(() =>
|
||||
(productTypes.value || []) as ProductCatalogType[],
|
||||
@@ -238,6 +263,12 @@ const selectedType = computed(() => {
|
||||
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(
|
||||
() => route.query.typeId,
|
||||
(value) => {
|
||||
@@ -264,27 +295,16 @@ watch(selectedTypeId, (id) => {
|
||||
watch(selectedType, (type) => {
|
||||
if (!type) {
|
||||
clearForm()
|
||||
customFieldInputs.value = []
|
||||
cfDefinitions.value = []
|
||||
return
|
||||
}
|
||||
if (!creationForm.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(
|
||||
canEdit.value &&
|
||||
selectedType.value &&
|
||||
@@ -336,7 +356,8 @@ const submitCreation = async () => {
|
||||
const result = await createProduct(payload)
|
||||
if (result.success && 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) {
|
||||
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
|
||||
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
|
||||
watch(
|
||||
() => creationForm.constructeurIds,
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
import { formatPhone } from '~/utils/formatters/phone';
|
||||
|
||||
export interface ConstructeurTelephoneSummary {
|
||||
numero?: string | null;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export interface ConstructeurSummary {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
// Legacy single-phone string: still exposed by the machine-structure normalization.
|
||||
phone?: string | null;
|
||||
// Multi-phone list: exposed by the /constructeurs API resource.
|
||||
telephones?: ConstructeurTelephoneSummary[] | null;
|
||||
}
|
||||
|
||||
type ConstructeurPhoneSource = {
|
||||
phone?: string | null;
|
||||
telephones?: ConstructeurTelephoneSummary[] | null;
|
||||
} | null | undefined;
|
||||
|
||||
export const constructeurPhones = (
|
||||
constructeur: ConstructeurPhoneSource,
|
||||
): Array<{ numero: string; label: string | null }> => {
|
||||
if (!constructeur) {
|
||||
return [];
|
||||
}
|
||||
const list = Array.isArray(constructeur.telephones)
|
||||
? constructeur.telephones
|
||||
.filter((t): t is ConstructeurTelephoneSummary => Boolean(t && t.numero && String(t.numero).trim()))
|
||||
.map(t => ({ numero: String(t.numero).trim(), label: (t.label ?? null) || null }))
|
||||
: [];
|
||||
if (!list.length && constructeur.phone && constructeur.phone.trim()) {
|
||||
return [{ numero: constructeur.phone.trim(), label: null }];
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
export const constructeurPrimaryPhone = (
|
||||
constructeur: ConstructeurPhoneSource,
|
||||
): string | null => {
|
||||
const phones = constructeurPhones(constructeur);
|
||||
return phones.length ? phones[0]!.numero : null;
|
||||
};
|
||||
|
||||
export interface ConstructeurLinkEntry {
|
||||
linkId?: string;
|
||||
constructeurId: string;
|
||||
@@ -133,8 +170,8 @@ export const formatConstructeurContact = (
|
||||
return '';
|
||||
}
|
||||
|
||||
const formattedPhone = formatPhone(constructeur.phone);
|
||||
const phone = formattedPhone || constructeur.phone || null;
|
||||
const primary = constructeurPrimaryPhone(constructeur);
|
||||
const phone = formatPhone(primary) || primary || null;
|
||||
|
||||
return [constructeur.email, phone].filter(Boolean).join(' • ');
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ComponentModelStructure,
|
||||
type ComponentModelStructureNode,
|
||||
} from '../types/inventory'
|
||||
import { mergeDefinitionsWithValues } from '../utils/customFields'
|
||||
|
||||
// Import for internal use in this file
|
||||
import { sanitizeCustomFields, sanitizePieces, sanitizeProducts, sanitizeSubcomponents } from './componentStructureSanitize'
|
||||
@@ -86,30 +87,22 @@ export const cloneStructure = (input: any): ComponentModelStructure => {
|
||||
export const normalizeStructureForEditor = (input: any): ComponentModelStructure => {
|
||||
const source = cloneStructure(input)
|
||||
|
||||
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
|
||||
const customFields = sanitizedCustomFields.map((field) => {
|
||||
const options = Array.isArray(field.options) ? [...field.options] : []
|
||||
const optionsText = options.length ? options.join('\n') : ''
|
||||
const defaultValue =
|
||||
field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== ''
|
||||
? String(field.defaultValue)
|
||||
: null
|
||||
const copy: ComponentModelCustomField = {
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
machineContextOnly: !!field.machineContextOnly,
|
||||
options,
|
||||
defaultValue,
|
||||
optionsText,
|
||||
id: field.id,
|
||||
customFieldId: field.customFieldId,
|
||||
}
|
||||
return copy
|
||||
})
|
||||
const merged = mergeDefinitionsWithValues(source.customFields, [])
|
||||
const customFields: ComponentModelCustomField[] = merged.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type as ComponentModelCustomField['type'],
|
||||
required: field.required,
|
||||
machineContextOnly: field.machineContextOnly,
|
||||
options: field.options,
|
||||
defaultValue: field.defaultValue,
|
||||
optionsText: field.optionsText,
|
||||
id: field.customFieldId ?? undefined,
|
||||
customFieldId: field.customFieldId ?? undefined,
|
||||
orderIndex: field.orderIndex,
|
||||
}))
|
||||
|
||||
const result: ComponentModelStructure = {
|
||||
customFields: customFields as ComponentModelCustomField[],
|
||||
customFields,
|
||||
pieces: sanitizePieces(source.pieces),
|
||||
products: sanitizeProducts(source.products),
|
||||
subcomponents: hydrateSubcomponents(source.subcomponents),
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface DataTableColumn {
|
||||
headerClass?: string
|
||||
/** Width hint (e.g. 'w-24', 'min-w-[10rem]') */
|
||||
width?: string
|
||||
/** Inline min-width style (e.g. '120px', '8rem'). Only effective with fixedLayout. */
|
||||
minWidth?: string
|
||||
/** Text alignment: 'left' (default), 'center', 'right' */
|
||||
align?: 'left' | 'center' | 'right'
|
||||
/** Hide on mobile (adds 'hidden sm:table-cell') */
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
* 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[] {
|
||||
if (Array.isArray(payload)) return payload as T[]
|
||||
const p = payload as Record<string, unknown> | null
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user