Compare commits
178 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e05ba6a97c | |||
| 012d552ddc | |||
| 594ed7b631 | |||
| 7836f87cd2 | |||
| d5361ac3ec | |||
| 477295c400 | |||
| 22dddb73bd | |||
| cb49c69662 | |||
| f18ae545d8 | |||
| 3003ced157 | |||
| 2b318ce5d6 | |||
| c10ab08803 | |||
| 85d4726415 | |||
| af13dc0237 | |||
| 7e2cabfa65 | |||
| 003e419a93 | |||
| d1b170d87f | |||
| 0fc9daa974 | |||
| 104942a52b | |||
| c65757ee24 | |||
| 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 | |||
| d0aba111b3 | |||
| 6eaefdbbbf | |||
| b869984609 | |||
| 59fae38176 | |||
| a674a5f2f0 | |||
| 0049638e3c | |||
| c54e5c33f2 | |||
| 51b491097e | |||
| 7da78b3b3e | |||
| a88e4a68fb | |||
| 61584f2f9b | |||
| 970708ea83 | |||
| 7b674fcc0c | |||
| 99147f4e08 | |||
| aec33e7911 | |||
| 2edb748bd4 | |||
| da12955b52 | |||
| 1385d7768c | |||
| 500b6b1620 | |||
| 54203db328 | |||
| d085c48953 | |||
| 4f1f643436 | |||
| 1c3b566923 | |||
| 342ae37762 | |||
| 1529d21f12 | |||
| d6441bef06 | |||
| 3cf9db8829 |
+1
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Project Overview
|
||||
|
||||
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
|
||||
Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
|
||||
Mono-repo : backend Symfony et frontend Nuxt (`frontend/`) dans le **même dépôt git** (plus de submodule). Un seul commit/push couvre backend + frontend.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -43,7 +43,7 @@ Inventory/ # Backend Symfony (repo principal)
|
||||
├── pre-commit, commit-msg # Git hooks
|
||||
├── makefile # Commandes Docker/dev
|
||||
├── VERSION # Source unique de version (semver)
|
||||
├── frontend/ # ← SUBMODULE GIT (repo séparé)
|
||||
├── frontend/ # ← Frontend Nuxt (DANS le même repo, pas un submodule)
|
||||
│ ├── app/pages/ # Pages Nuxt (file-based routing)
|
||||
│ ├── app/components/ # Composants Vue (auto-imported)
|
||||
│ ├── app/composables/ # Composables Vue
|
||||
@@ -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)
|
||||
```
|
||||
@@ -107,16 +112,17 @@ Exemples :
|
||||
1. php-cs-fixer sur les fichiers PHP stagés
|
||||
2. PHPUnit — bloque le commit si tests échouent
|
||||
|
||||
### Submodule Workflow
|
||||
Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
1. Commit dans `frontend/` d'abord
|
||||
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
|
||||
3. Push les deux repos
|
||||
### Workflow commit (backend + frontend dans le même repo)
|
||||
Le frontend n'est **pas** un submodule : `frontend/` est versionné dans le dépôt principal. Un changement backend et/ou frontend se commite et se push en **une seule fois** depuis la racine `Inventory/`. Pas de double commit ni de pointeur de submodule à gérer.
|
||||
- Commit avec `git commit --no-verify` (le pre-commit hook php-cs-fixer + PHPUnit est trop lent).
|
||||
- Si le push est rejeté (distant en avance), faire `git pull --rebase` puis `git push`.
|
||||
|
||||
## 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 +205,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
|
||||
@@ -220,7 +227,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
### Toujours faire AVANT de modifier du code
|
||||
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
|
||||
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
|
||||
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
|
||||
3. **Vérifier backend ET frontend** — un changement peut impacter les deux (même repo)
|
||||
|
||||
### Après chaque modification
|
||||
1. Backend PHP : `make php-cs-fixer-allow-risky`
|
||||
@@ -235,10 +242,9 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
- Force push sans confirmation explicite
|
||||
- Modifier la config git
|
||||
|
||||
### Submodule — Synchronisation
|
||||
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
|
||||
- Main repo : `git checkout master && git merge develop && git push`
|
||||
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
|
||||
### Synchronisation master ↔ develop
|
||||
Un seul repo (backend + frontend). Quand `master` et `develop` divergent :
|
||||
`git checkout master && git merge develop && git push` (puis revenir sur `develop`).
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -256,7 +262,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 +270,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.*",
|
||||
|
||||
Generated
+433
-1
@@ -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],
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
api_platform:
|
||||
title: Inventory API
|
||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||
version: 1.9.6
|
||||
version: 1.9.40
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
|
||||
@@ -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
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.10'
|
||||
app.version: '1.9.47'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,841 @@
|
||||
# Machine Context Custom Fields — Backend 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.
|
||||
> **Parallel plan:** This is the backend half. The frontend plan is at `2026-04-02-machine-context-fields-frontend.md`. Both can run in parallel on separate worktrees — they share no files.
|
||||
|
||||
**Goal:** Add `machineContextOnly` flag on `CustomField`, link FKs on `CustomFieldValue`, structure controller normalization, upsert support, clone support, and tests.
|
||||
|
||||
**Architecture:** `CustomField.machineContextOnly` flags definitions. `CustomFieldValue` gets nullable FKs to `MachineComponentLink`/`MachinePieceLink`. The structure response returns `contextCustomFields` and `contextCustomFieldValues` per link. Upsert and clone are extended.
|
||||
|
||||
**Tech Stack:** Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, PHPUnit 12
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Create
|
||||
- `migrations/VersionXXXX_MachineContextCustomFields.php` — migration
|
||||
- `tests/Api/Entity/MachineContextCustomFieldTest.php` — test class
|
||||
|
||||
### Modify
|
||||
- `src/Entity/CustomField.php` — add `machineContextOnly` property
|
||||
- `src/Entity/CustomFieldValue.php` — add `machineComponentLink` and `machinePieceLink` FKs
|
||||
- `src/Entity/MachineComponentLink.php` — add `contextFieldValues` collection
|
||||
- `src/Entity/MachinePieceLink.php` — add `contextFieldValues` collection
|
||||
- `src/Controller/MachineStructureController.php` — normalize context fields + clone
|
||||
- `src/Controller/CustomFieldValueController.php` — link-based upsert
|
||||
- `tests/AbstractApiTestCase.php` — extend factories
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Migration + Entity `CustomField` — `machineContextOnly`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/CustomField.php` (add property after line 56)
|
||||
|
||||
- [ ] **Step 1: Add `machineContextOnly` property to `CustomField` entity**
|
||||
|
||||
In `src/Entity/CustomField.php`, add after the `$required` property (line 56):
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private bool $machineContextOnly = false;
|
||||
```
|
||||
|
||||
Add getter/setter before the closing `}`:
|
||||
|
||||
```php
|
||||
public function isMachineContextOnly(): bool
|
||||
{
|
||||
return $this->machineContextOnly;
|
||||
}
|
||||
|
||||
public function setMachineContextOnly(bool $machineContextOnly): static
|
||||
{
|
||||
$this->machineContextOnly = $machineContextOnly;
|
||||
|
||||
return $this;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Generate and adjust migration**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
|
||||
```
|
||||
|
||||
Edit the generated migration to use idempotent SQL:
|
||||
|
||||
```sql
|
||||
ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run migration**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/CustomField.php migrations/
|
||||
git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Entity `CustomFieldValue` — link FKs + inverse collections
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/CustomFieldValue.php` (add after line 67 — `$product` property)
|
||||
- Modify: `src/Entity/MachineComponentLink.php` (add after line 72 — `$productLinks`)
|
||||
- Modify: `src/Entity/MachinePieceLink.php` (add after line 61 — `$productLinks`)
|
||||
|
||||
- [ ] **Step 1: Add FKs to `CustomFieldValue`**
|
||||
|
||||
In `src/Entity/CustomFieldValue.php`, add after the `$product` property (line 67):
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
|
||||
#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?MachineComponentLink $machineComponentLink = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
|
||||
#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?MachinePieceLink $machinePieceLink = null;
|
||||
```
|
||||
|
||||
Add getters/setters before the closing `}`:
|
||||
|
||||
```php
|
||||
public function getMachineComponentLink(): ?MachineComponentLink
|
||||
{
|
||||
return $this->machineComponentLink;
|
||||
}
|
||||
|
||||
public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static
|
||||
{
|
||||
$this->machineComponentLink = $machineComponentLink;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMachinePieceLink(): ?MachinePieceLink
|
||||
{
|
||||
return $this->machinePieceLink;
|
||||
}
|
||||
|
||||
public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static
|
||||
{
|
||||
$this->machinePieceLink = $machinePieceLink;
|
||||
|
||||
return $this;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `contextFieldValues` collection to `MachineComponentLink`**
|
||||
|
||||
In `src/Entity/MachineComponentLink.php`, add after the `$productLinks` collection (line 72):
|
||||
|
||||
```php
|
||||
/**
|
||||
* @var Collection<int, CustomFieldValue>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contextFieldValues;
|
||||
```
|
||||
|
||||
In the constructor (line 95), add:
|
||||
```php
|
||||
$this->contextFieldValues = new ArrayCollection();
|
||||
```
|
||||
|
||||
Add getter before the closing `}`:
|
||||
```php
|
||||
/**
|
||||
* @return Collection<int, CustomFieldValue>
|
||||
*/
|
||||
public function getContextFieldValues(): Collection
|
||||
{
|
||||
return $this->contextFieldValues;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `contextFieldValues` collection to `MachinePieceLink`**
|
||||
|
||||
In `src/Entity/MachinePieceLink.php`, add after the `$productLinks` collection (line 61):
|
||||
|
||||
```php
|
||||
/**
|
||||
* @var Collection<int, CustomFieldValue>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contextFieldValues;
|
||||
```
|
||||
|
||||
In the constructor (line 86), add:
|
||||
```php
|
||||
$this->contextFieldValues = new ArrayCollection();
|
||||
```
|
||||
|
||||
Add getter:
|
||||
```php
|
||||
/**
|
||||
* @return Collection<int, CustomFieldValue>
|
||||
*/
|
||||
public function getContextFieldValues(): Collection
|
||||
{
|
||||
return $this->contextFieldValues;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Generate and adjust migration**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
|
||||
```
|
||||
|
||||
Edit migration to use idempotent SQL:
|
||||
```sql
|
||||
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL;
|
||||
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN
|
||||
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link
|
||||
FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN
|
||||
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link
|
||||
FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run migration + linter**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/CustomFieldValue.php src/Entity/MachineComponentLink.php src/Entity/MachinePieceLink.php migrations/
|
||||
git commit -m "feat(custom-fields) : add link FKs to CustomFieldValue for machine context"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Test factories — extend helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/AbstractApiTestCase.php:399-461`
|
||||
|
||||
- [ ] **Step 1: Update `createCustomField()` factory**
|
||||
|
||||
In `tests/AbstractApiTestCase.php`, add `bool $machineContextOnly = false` parameter at the end of `createCustomField` (line 399), and add `$cf->setMachineContextOnly($machineContextOnly);` after `$cf->setOrderIndex($orderIndex);` (line 411).
|
||||
|
||||
- [ ] **Step 2: Update `createCustomFieldValue()` factory**
|
||||
|
||||
Add two new nullable parameters at the end of `createCustomFieldValue` (line 432):
|
||||
|
||||
```php
|
||||
?MachineComponentLink $machineComponentLink = null,
|
||||
?MachinePieceLink $machinePieceLink = null,
|
||||
```
|
||||
|
||||
Add the corresponding setter calls **after the closing `}` of the `if (null !== $product)` block** (after line 454, NOT inside it):
|
||||
|
||||
```php
|
||||
if (null !== $machineComponentLink) {
|
||||
$cfv->setMachineComponentLink($machineComponentLink);
|
||||
}
|
||||
if (null !== $machinePieceLink) {
|
||||
$cfv->setMachinePieceLink($machinePieceLink);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/AbstractApiTestCase.php
|
||||
git commit -m "test(custom-fields) : extend factories for machineContextOnly and link params"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `MachineStructureController` — normalize context fields
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/MachineStructureController.php`
|
||||
|
||||
- [ ] **Step 1: Add `machineContextOnly` to all normalization methods**
|
||||
|
||||
In `normalizeCustomFields` (line 601), add to the output array at line 615:
|
||||
```php
|
||||
'machineContextOnly' => $customField->isMachineContextOnly(),
|
||||
```
|
||||
|
||||
In `normalizeCustomFieldDefinitions` (line 838), add to the output array at line 852:
|
||||
```php
|
||||
'machineContextOnly' => $cf->isMachineContextOnly(),
|
||||
```
|
||||
|
||||
In `normalizeCustomFieldValues` (line 861), add to the nested `customField` array at line 879:
|
||||
```php
|
||||
'machineContextOnly' => $cf->isMachineContextOnly(),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `normalizeContextCustomFieldDefinitions` helper**
|
||||
|
||||
Add a new private method after `normalizeCustomFieldValues`:
|
||||
|
||||
```php
|
||||
private function normalizeContextCustomFieldDefinitions(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $cf) {
|
||||
if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
'machineContextOnly' => true,
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||
|
||||
return $items;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `normalizeComponentLinks` to include context fields**
|
||||
|
||||
In `normalizeComponentLinks` (line 622), add `$type` variable and context field keys to the returned array:
|
||||
|
||||
```php
|
||||
private function normalizeComponentLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineComponentLink $link): array {
|
||||
$composant = $link->getComposant();
|
||||
$parentLink = $link->getParentLink();
|
||||
$type = $composant->getTypeComposant();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `normalizePieceLinks` to include context fields**
|
||||
|
||||
In `normalizePieceLinks` (line 644):
|
||||
|
||||
```php
|
||||
private function normalizePieceLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachinePieceLink $link): array {
|
||||
$piece = $link->getPiece();
|
||||
$parentLink = $link->getParentLink();
|
||||
$type = $piece->getTypePiece();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [],
|
||||
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/MachineStructureController.php
|
||||
git commit -m "feat(custom-fields) : expose context custom fields in machine structure response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `CustomFieldValueController` — support link-based upsert
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/CustomFieldValueController.php`
|
||||
|
||||
- [ ] **Step 1: Inject link repositories in constructor**
|
||||
|
||||
In the constructor (line 24), add:
|
||||
|
||||
```php
|
||||
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||
```
|
||||
|
||||
Add use statements at the top:
|
||||
```php
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend `resolveTarget` to support link entities**
|
||||
|
||||
In `resolveTarget` (line 211), the method applies `strtolower()` on entityType at line 213. The candidate loop and match block must handle this.
|
||||
|
||||
Update the candidate list in the foreach (line 217):
|
||||
```php
|
||||
foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) {
|
||||
```
|
||||
|
||||
**IMPORTANT:** The candidate loop assigns `$entityType` in camelCase, but `strtolower()` (line 213) only applies when `entityType` comes from the payload directly. Add a normalization after the loop closes (after the existing `break;` at line 226), before the empty-check at line 229:
|
||||
|
||||
```php
|
||||
$entityType = strtolower($entityType);
|
||||
```
|
||||
|
||||
This ensures both code paths (direct payload `entityType` and candidate loop) deliver lowercase to the match block.
|
||||
|
||||
Update the match block (line 233) — all keys lowercase, but `resolveEntity` returns camelCase type for `applyTarget`:
|
||||
|
||||
```php
|
||||
return match ($entityType) {
|
||||
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
|
||||
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
|
||||
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
|
||||
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
|
||||
'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository),
|
||||
'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository),
|
||||
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extend `applyTarget` for link entities**
|
||||
|
||||
Add two new cases in `applyTarget` (line 252):
|
||||
|
||||
```php
|
||||
case 'machineComponentLink':
|
||||
$value->setMachineComponentLink($entity);
|
||||
$value->setComposant($entity->getComposant());
|
||||
|
||||
break;
|
||||
|
||||
case 'machinePieceLink':
|
||||
$value->setMachinePieceLink($entity);
|
||||
$value->setPiece($entity->getPiece());
|
||||
|
||||
break;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/CustomFieldValueController.php
|
||||
git commit -m "feat(custom-fields) : support link-based upsert in CustomFieldValueController"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Clone support — copy context field values
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/MachineStructureController.php` (after `cloneProductLinks`, before `flush` in `cloneMachine`)
|
||||
|
||||
- [ ] **Step 1: Add `cloneContextFieldValues` helper method**
|
||||
|
||||
Add after `cloneProductLinks`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||
* @param array<string, MachinePieceLink> $pieceLinkMap
|
||||
*/
|
||||
private function cloneContextFieldValues(
|
||||
array $componentLinkMap,
|
||||
array $pieceLinkMap,
|
||||
): void {
|
||||
foreach ($componentLinkMap as $oldLinkId => $newLink) {
|
||||
$oldLink = $this->machineComponentLinkRepository->find($oldLinkId);
|
||||
if (!$oldLink) {
|
||||
continue;
|
||||
}
|
||||
foreach ($oldLink->getContextFieldValues() as $cfv) {
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$newValue->setMachineComponentLink($newLink);
|
||||
$newValue->setComposant($newLink->getComposant());
|
||||
$this->entityManager->persist($newValue);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pieceLinkMap as $oldLinkId => $newLink) {
|
||||
$oldLink = $this->machinePieceLinkRepository->find($oldLinkId);
|
||||
if (!$oldLink) {
|
||||
continue;
|
||||
}
|
||||
foreach ($oldLink->getContextFieldValues() as $cfv) {
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$newValue->setMachinePieceLink($newLink);
|
||||
$newValue->setPiece($newLink->getPiece());
|
||||
$this->entityManager->persist($newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Call from `cloneMachine` method**
|
||||
|
||||
In `cloneMachine` (line 113), after `$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);` (line 163) and before `$this->entityManager->flush();` (line 165), add:
|
||||
|
||||
```php
|
||||
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/MachineStructureController.php
|
||||
git commit -m "feat(custom-fields) : clone context field values on machine clone"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Backend tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Api/Entity/MachineContextCustomFieldTest.php`
|
||||
|
||||
- [ ] **Step 1: Write test class**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Entity;
|
||||
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testStructureReturnsContextFieldsOnComponentLink(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site A');
|
||||
$modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT);
|
||||
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Voltage',
|
||||
type: 'number',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
$normalField = $this->createCustomField(
|
||||
name: 'Serial',
|
||||
type: 'text',
|
||||
typeComposant: $modelType,
|
||||
);
|
||||
|
||||
$machine = $this->createMachine('Machine A', $site);
|
||||
$composant = $this->createComposant('Motor 1', 'MOT-001', $modelType);
|
||||
$link = $this->createMachineComponentLink($machine, $composant);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '220',
|
||||
composant: $composant,
|
||||
machineComponentLink: $link,
|
||||
);
|
||||
|
||||
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$data = $response->toArray();
|
||||
$componentLink = $data['componentLinks'][0];
|
||||
|
||||
$this->assertArrayHasKey('contextCustomFields', $componentLink);
|
||||
$this->assertCount(1, $componentLink['contextCustomFields']);
|
||||
$this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']);
|
||||
$this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']);
|
||||
|
||||
$this->assertArrayHasKey('contextCustomFieldValues', $componentLink);
|
||||
$this->assertCount(1, $componentLink['contextCustomFieldValues']);
|
||||
$this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']);
|
||||
|
||||
$normalFields = array_filter(
|
||||
$componentLink['composant']['customFields'],
|
||||
fn (array $f) => $f['name'] === 'Serial',
|
||||
);
|
||||
$this->assertCount(1, $normalFields);
|
||||
}
|
||||
|
||||
public function testStructureReturnsContextFieldsOnPieceLink(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site B');
|
||||
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Wear Level',
|
||||
type: 'select',
|
||||
typePiece: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
$contextField->setOptions(['Good', 'Fair', 'Replace']);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$machine = $this->createMachine('Machine B', $site);
|
||||
$piece = $this->createPiece('Bearing 1', 'BRG-001', $modelType);
|
||||
$link = $this->createMachinePieceLink($machine, $piece);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: 'Fair',
|
||||
piece: $piece,
|
||||
machinePieceLink: $link,
|
||||
);
|
||||
|
||||
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
|
||||
$data = $response->toArray();
|
||||
|
||||
$pieceLink = $data['pieceLinks'][0];
|
||||
$this->assertCount(1, $pieceLink['contextCustomFields']);
|
||||
$this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']);
|
||||
$this->assertCount(1, $pieceLink['contextCustomFieldValues']);
|
||||
$this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
|
||||
public function testUpsertContextFieldValueViaComponentLink(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site C');
|
||||
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Flow Rate',
|
||||
type: 'number',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$machine = $this->createMachine('Machine C', $site);
|
||||
$composant = $this->createComposant('Pump 1', 'PMP-001', $modelType);
|
||||
$link = $this->createMachineComponentLink($machine, $composant);
|
||||
|
||||
$response = $client->request('POST', '/api/custom-fields/values/upsert', [
|
||||
'json' => [
|
||||
'customFieldId' => $contextField->getId(),
|
||||
'machineComponentLinkId' => $link->getId(),
|
||||
'value' => '380',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
$this->assertSame('380', $data['value']);
|
||||
}
|
||||
|
||||
public function testSameComposantDifferentValuesPerMachine(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site D');
|
||||
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Pressure',
|
||||
type: 'number',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$machineA = $this->createMachine('Machine A', $site);
|
||||
$machineB = $this->createMachine('Machine B', $site);
|
||||
$composant = $this->createComposant('Valve 1', 'VLV-001', $modelType);
|
||||
|
||||
$linkA = $this->createMachineComponentLink($machineA, $composant);
|
||||
$linkB = $this->createMachineComponentLink($machineB, $composant);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '100',
|
||||
composant: $composant,
|
||||
machineComponentLink: $linkA,
|
||||
);
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '200',
|
||||
composant: $composant,
|
||||
machineComponentLink: $linkB,
|
||||
);
|
||||
|
||||
$dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray();
|
||||
$this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
|
||||
|
||||
$dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray();
|
||||
$this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
|
||||
public function testMachineContextOnlyFieldSerialization(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site E');
|
||||
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Calibration Date',
|
||||
type: 'date',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$response = $client->request('GET', '/api/custom_fields/'.$contextField->getId());
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
$this->assertTrue($data['machineContextOnly']);
|
||||
}
|
||||
|
||||
public function testCloneMachineCopiesContextFieldValues(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site F');
|
||||
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'RPM Setting',
|
||||
type: 'number',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$source = $this->createMachine('Source Machine', $site);
|
||||
$composant = $this->createComposant('Motor C', 'MOTC-001', $modelType);
|
||||
$link = $this->createMachineComponentLink($source, $composant);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '3000',
|
||||
composant: $composant,
|
||||
machineComponentLink: $link,
|
||||
);
|
||||
|
||||
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||
'json' => [
|
||||
'name' => 'Cloned Machine',
|
||||
'siteId' => $site->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
|
||||
$clonedLink = $data['componentLinks'][0] ?? null;
|
||||
$this->assertNotNull($clonedLink);
|
||||
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
||||
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
```bash
|
||||
make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php
|
||||
```
|
||||
|
||||
Expected: All 6 tests pass.
|
||||
|
||||
- [ ] **Step 3: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: All existing tests still pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Api/Entity/MachineContextCustomFieldTest.php
|
||||
git commit -m "test(custom-fields) : add tests for machine context custom fields"
|
||||
```
|
||||
@@ -0,0 +1,404 @@
|
||||
# Machine Context Custom Fields — Frontend 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.
|
||||
> **Parallel plan:** This is the frontend half. The backend plan is at `2026-04-02-machine-context-fields-backend.md`. Both can run in parallel on separate worktrees — they share no files. Frontend tests requiring the API will need the backend done first.
|
||||
|
||||
**Goal:** Add `machineContextOnly` toggle in structure editors, filter context fields from standalone pages, and display/edit them in the machine detail view.
|
||||
|
||||
**Architecture:** Structure editors get a checkbox per field. The machine-detail transform propagates `contextCustomFields`/`contextCustomFieldValues` from the API link response onto the component/piece objects. Standalone entity views filter these out. Machine view displays them in a separate "Champs contextuels" section using the existing `CustomFieldDisplay` component, saving via upsert with the link ID.
|
||||
|
||||
**Tech Stack:** Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Modify
|
||||
- `frontend/app/shared/types/inventory.ts` — add `machineContextOnly` to custom field types
|
||||
- `frontend/app/components/PieceModelStructureEditor.vue` — add checkbox toggle
|
||||
- `frontend/app/composables/usePieceStructureEditorLogic.ts` — add default in `createEmptyField()`
|
||||
- `frontend/app/components/StructureNodeEditor.vue` — add checkbox toggle
|
||||
- `frontend/app/composables/useStructureNodeCrud.ts` — add default in `addCustomField()`
|
||||
- `frontend/app/composables/useEntityCustomFields.ts` — filter out `machineContextOnly` fields
|
||||
- `frontend/app/composables/useMachineDetailCustomFields.ts` — propagate context fields, filter from normal merge
|
||||
- `frontend/app/components/ComponentItem.vue` — display context custom fields section
|
||||
- `frontend/app/components/PieceItem.vue` — display context custom fields section
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Types — add `machineContextOnly`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/shared/types/inventory.ts`
|
||||
|
||||
- [ ] **Step 1: Add `machineContextOnly` to `ComponentModelCustomField`**
|
||||
|
||||
In the `ComponentModelCustomField` interface (around line 14), add:
|
||||
|
||||
```typescript
|
||||
machineContextOnly?: boolean
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `machineContextOnly` to `PieceModelCustomField`**
|
||||
|
||||
In the `PieceModelCustomField` interface (around line 65), add:
|
||||
|
||||
```typescript
|
||||
machineContextOnly?: boolean
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd frontend && git add app/shared/types/inventory.ts
|
||||
git commit -m "feat(custom-fields) : add machineContextOnly to custom field types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Structure editors — add toggle
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/PieceModelStructureEditor.vue:122-125`
|
||||
- Modify: `frontend/app/composables/usePieceStructureEditorLogic.ts:283-290`
|
||||
- Modify: `frontend/app/components/StructureNodeEditor.vue:121-125`
|
||||
- Modify: `frontend/app/composables/useStructureNodeCrud.ts:49-62`
|
||||
|
||||
- [ ] **Step 1: Add toggle in `PieceModelStructureEditor.vue`**
|
||||
|
||||
After the "Obligatoire" checkbox block (line 125, after `</div>` closing the required checkbox), add:
|
||||
|
||||
```vue
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
|
||||
Contexte machine uniquement
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `usePieceStructureEditorLogic.ts` — 3 functions**
|
||||
|
||||
**a) `createEmptyField`** (line 283) — add `machineContextOnly: false` to the returned object:
|
||||
|
||||
```typescript
|
||||
const createEmptyField = (orderIndex: number): EditorField => ({
|
||||
uid: createUid('field'),
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
machineContextOnly: false,
|
||||
orderIndex,
|
||||
})
|
||||
```
|
||||
|
||||
**b) `toEditorField`** (line 78-91) — add `machineContextOnly` to the returned object, after the `orderIndex` line (line 90):
|
||||
|
||||
```typescript
|
||||
machineContextOnly: Boolean(input?.machineContextOnly),
|
||||
```
|
||||
|
||||
**c) `buildPayload`** (line 160-165) — add `machineContextOnly` to the `payload` object after `orderIndex` (line 164):
|
||||
|
||||
```typescript
|
||||
machineContextOnly: Boolean(field.machineContextOnly),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add toggle in `StructureNodeEditor.vue`**
|
||||
|
||||
After the "Obligatoire" checkbox closing `</div>` (line 123) and **before** the `<textarea>` for select options (line 124), add:
|
||||
|
||||
```vue
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
|
||||
Contexte machine uniquement
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `addCustomField` in `useStructureNodeCrud.ts`**
|
||||
|
||||
In `addCustomField` (line 49), add `machineContextOnly: false` to the pushed object (line 53-60):
|
||||
|
||||
```typescript
|
||||
fields.push({
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
options: [],
|
||||
machineContextOnly: false,
|
||||
orderIndex: nextIndex,
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd frontend && git add .
|
||||
git commit -m "feat(custom-fields) : add machineContextOnly toggle in structure editors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Filter context fields on standalone pages + machine-detail transform
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/composables/useEntityCustomFields.ts:42-49`
|
||||
- Modify: `frontend/app/composables/useMachineDetailCustomFields.ts:141-154,241-256`
|
||||
|
||||
- [ ] **Step 1: Filter `machineContextOnly` from `displayedCustomFields` in `useEntityCustomFields.ts`**
|
||||
|
||||
Update the `displayedCustomFields` computed (line 42):
|
||||
|
||||
```typescript
|
||||
const displayedCustomFields = computed(() =>
|
||||
dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(
|
||||
definitionSources.value,
|
||||
entity().customFieldValues,
|
||||
),
|
||||
).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Filter `machineContextOnly` from normal customFields in machine-detail transform and propagate context data**
|
||||
|
||||
In `frontend/app/composables/useMachineDetailCustomFields.ts`:
|
||||
|
||||
**For pieces** — In `transformCustomFields` (line 70), the returned object is built at line 141. Replace the `customFields,` line (line 143) with:
|
||||
|
||||
```typescript
|
||||
customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
|
||||
contextCustomFields: piece.contextCustomFields ?? [],
|
||||
contextCustomFieldValues: piece.contextCustomFieldValues ?? [],
|
||||
```
|
||||
|
||||
**For components** — In `transformComponentCustomFields` (line 158), the returned object is built at line 241. Replace the `customFields,` line (line 243) with:
|
||||
|
||||
```typescript
|
||||
customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
|
||||
contextCustomFields: component.contextCustomFields ?? [],
|
||||
contextCustomFieldValues: component.contextCustomFieldValues ?? [],
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd frontend && git add .
|
||||
git commit -m "feat(custom-fields) : filter machineContextOnly from standalone and machine-detail views"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Display context fields in machine view — `ComponentItem.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/ComponentItem.vue`
|
||||
|
||||
Context fields are on the `component` object (set by the transform in Task 3), not as separate props.
|
||||
|
||||
- [ ] **Step 1: Add template section**
|
||||
|
||||
After the existing `CustomFieldDisplay` block (line 195), add:
|
||||
|
||||
```vue
|
||||
<!-- Context custom fields (machine-specific) -->
|
||||
<div v-if="mergedContextFields.length" class="mt-4">
|
||||
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
|
||||
Champs contextuels
|
||||
</h4>
|
||||
<CustomFieldDisplay
|
||||
:fields="mergedContextFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
:columns="2"
|
||||
@field-blur="updateContextCustomField"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add imports and script logic**
|
||||
|
||||
**IMPORTANT:** `ComponentItem.vue` uses `<script setup>` WITHOUT `lang="ts"`. Do NOT use TypeScript annotations (`: any`, `: string`, etc.) in any code added to this file.
|
||||
|
||||
Add these imports (they are NOT already present in the component):
|
||||
|
||||
```javascript
|
||||
import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
```
|
||||
|
||||
Add after the existing `useEntityCustomFields` block (around line 348):
|
||||
|
||||
```javascript
|
||||
const { upsertCustomFieldValue } = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const mergedContextFields = computed(() => {
|
||||
const definitions = props.component?.contextCustomFields ?? []
|
||||
const values = props.component?.contextCustomFieldValues ?? []
|
||||
if (!definitions.length && !values.length) return []
|
||||
return dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(definitions, values),
|
||||
)
|
||||
})
|
||||
|
||||
const updateContextCustomField = async (field) => {
|
||||
const linkId = props.component?.linkId
|
||||
if (!linkId || !field) return
|
||||
|
||||
const customFieldId = field.customFieldId || field.customField?.id
|
||||
if (!customFieldId) return
|
||||
|
||||
const result = await upsertCustomFieldValue(
|
||||
customFieldId,
|
||||
'machineComponentLink',
|
||||
linkId,
|
||||
field.value ?? '',
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ contextuel`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd frontend && git add app/components/ComponentItem.vue
|
||||
git commit -m "feat(custom-fields) : display context custom fields in ComponentItem"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Display context fields in machine view — `PieceItem.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/PieceItem.vue`
|
||||
|
||||
- [ ] **Step 1: Add template section**
|
||||
|
||||
After the existing `CustomFieldDisplay` block (line 236), add:
|
||||
|
||||
```vue
|
||||
<!-- Context custom fields (machine-specific) -->
|
||||
<div v-if="mergedContextFields.length" class="mt-4">
|
||||
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
|
||||
Champs contextuels
|
||||
</h4>
|
||||
<CustomFieldDisplay
|
||||
:fields="mergedContextFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
:columns="2"
|
||||
@field-blur="updateContextCustomField"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add imports and script logic**
|
||||
|
||||
**IMPORTANT:** `PieceItem.vue` uses `<script setup>` WITHOUT `lang="ts"`. Do NOT use TypeScript annotations in any code added to this file.
|
||||
|
||||
Add these imports (they are NOT already present in the component):
|
||||
|
||||
```javascript
|
||||
import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
```
|
||||
|
||||
Add after the existing `useEntityCustomFields` block (around line 366):
|
||||
|
||||
```javascript
|
||||
const { upsertCustomFieldValue } = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const mergedContextFields = computed(() => {
|
||||
const definitions = props.piece?.contextCustomFields ?? []
|
||||
const values = props.piece?.contextCustomFieldValues ?? []
|
||||
if (!definitions.length && !values.length) return []
|
||||
return dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(definitions, values),
|
||||
)
|
||||
})
|
||||
|
||||
const updateContextCustomField = async (field) => {
|
||||
const linkId = props.piece?.linkId
|
||||
if (!linkId || !field) return
|
||||
|
||||
const customFieldId = field.customFieldId || field.customField?.id
|
||||
if (!customFieldId) return
|
||||
|
||||
const result = await upsertCustomFieldValue(
|
||||
customFieldId,
|
||||
'machinePieceLink',
|
||||
linkId,
|
||||
field.value ?? '',
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ contextuel`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd frontend && git add app/components/PieceItem.vue
|
||||
git commit -m "feat(custom-fields) : display context custom fields in PieceItem"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Frontend build verification
|
||||
|
||||
- [ ] **Step 1: Run full build**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 2: Final commit — update submodule pointer (from main repo)**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Inventory
|
||||
git add frontend
|
||||
git commit -m "chore : update frontend submodule for context custom fields"
|
||||
```
|
||||
@@ -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`
|
||||
@@ -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,154 @@
|
||||
# Machine Context Custom Fields
|
||||
|
||||
**Date** : 2026-04-02
|
||||
**Statut** : Validé
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre de définir des champs personnalisés sur un ModelType (catégorie de pièce/composant) qui ne s'affichent et ne sont remplissables que lorsque l'item est lié à une machine. Les valeurs sont propres au lien machine (une même pièce dans deux machines peut avoir des valeurs différentes).
|
||||
|
||||
## Périmètre
|
||||
|
||||
- **Entités concernées** : Composants et Pièces (pas Produits)
|
||||
- **Définition** : Sur le ModelType, avec un flag `machineContextOnly`
|
||||
- **Valeurs** : Stockées par lien (`MachineComponentLink` / `MachinePieceLink`)
|
||||
- **Affichage** : Uniquement dans la vue machine, pas sur les fiches autonomes
|
||||
|
||||
## Architecture
|
||||
|
||||
### Approche retenue
|
||||
|
||||
Extension des entités existantes `CustomField` et `CustomFieldValue` avec :
|
||||
- Un flag de filtrage sur la définition
|
||||
- Des FK vers les entités de lien pour les valeurs
|
||||
|
||||
### Alternatives écartées
|
||||
|
||||
- **Entités séparées** (`MachineContextField` / `MachineContextFieldValue`) — trop de duplication de logique
|
||||
- **JSON sur les liens** — contraire au projet de normalisation JSON→tables en cours
|
||||
|
||||
## Backend
|
||||
|
||||
### 1. Entité `CustomField`
|
||||
|
||||
Nouveau champ :
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
#[Groups(['customField:read', 'customField:write'])]
|
||||
private bool $machineContextOnly = false;
|
||||
```
|
||||
|
||||
Getter/setter associés.
|
||||
|
||||
### 2. Entité `CustomFieldValue`
|
||||
|
||||
Nouvelles FK nullable :
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
|
||||
#[ORM\JoinColumn(nullable: true)]
|
||||
private ?MachineComponentLink $machineComponentLink = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
|
||||
#[ORM\JoinColumn(nullable: true)]
|
||||
private ?MachinePieceLink $machinePieceLink = null;
|
||||
```
|
||||
|
||||
Contrainte métier : quand `machineComponentLink` est set, `composant` reste aussi set (pour faciliter les queries par composant). Idem pour `machinePieceLink` + `piece`.
|
||||
|
||||
### 3. Entités `MachineComponentLink` / `MachinePieceLink`
|
||||
|
||||
Nouvelle collection :
|
||||
|
||||
```php
|
||||
#[ORM\OneToMany(targetEntity: CustomFieldValue::class, mappedBy: 'machineComponentLink', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contextFieldValues;
|
||||
```
|
||||
|
||||
Idem sur `MachinePieceLink` avec `mappedBy: 'machinePieceLink'`.
|
||||
|
||||
### 4. Migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE custom_field ADD machine_context_only BOOLEAN DEFAULT false NOT NULL;
|
||||
|
||||
ALTER TABLE custom_field_value ADD machine_component_link_id VARCHAR(36) DEFAULT NULL;
|
||||
ALTER TABLE custom_field_value ADD machine_piece_link_id VARCHAR(36) DEFAULT NULL;
|
||||
|
||||
ALTER TABLE custom_field_value
|
||||
ADD CONSTRAINT fk_cfv_machine_component_link
|
||||
FOREIGN KEY (machine_component_link_id) REFERENCES machine_component_link(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE custom_field_value
|
||||
ADD CONSTRAINT fk_cfv_machine_piece_link
|
||||
FOREIGN KEY (machine_piece_link_id) REFERENCES machine_piece_link(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_cfv_machine_component_link ON custom_field_value(machine_component_link_id);
|
||||
CREATE INDEX idx_cfv_machine_piece_link ON custom_field_value(machine_piece_link_id);
|
||||
```
|
||||
|
||||
### 5. `MachineStructureController`
|
||||
|
||||
Dans `normalizeComposant()` et `normalizePiece()` :
|
||||
- Récupérer les `CustomField` du ModelType où `machineContextOnly = true`
|
||||
- Récupérer les `CustomFieldValue` liées au lien via `machineComponentLink` / `machinePieceLink`
|
||||
- Ajouter dans la réponse :
|
||||
|
||||
```json
|
||||
{
|
||||
"contextCustomFields": [{ "id": "...", "name": "...", "type": "...", ... }],
|
||||
"contextCustomFieldValues": [{ "id": "...", "value": "...", "customField": {...} }]
|
||||
}
|
||||
```
|
||||
|
||||
Séparé des `customFields` / `customFieldValues` globaux existants.
|
||||
|
||||
### 6. `CustomFieldValueController`
|
||||
|
||||
L'upsert existant est étendu pour accepter `machineComponentLink` ou `machinePieceLink` dans le body. Le controller vérifie que si le `CustomField` a `machineContextOnly = true`, un lien machine est obligatoire.
|
||||
|
||||
### 7. Clonage machine
|
||||
|
||||
`MachineStructureController::cloneCustomFields()` doit aussi cloner les `contextFieldValues` des liens, en les rattachant aux nouveaux liens créés lors du clone.
|
||||
|
||||
## Frontend
|
||||
|
||||
### 1. Page ModelType — Définition des champs
|
||||
|
||||
Dans l'UI d'édition des custom fields d'un ModelType, ajouter un **toggle/checkbox** "Contexte machine uniquement" sur chaque définition de champ. Cela set `machineContextOnly: true` lors de la sauvegarde.
|
||||
|
||||
Concerne les custom fields des catégories COMPONENT et PIECE (pas PRODUCT, hors périmètre).
|
||||
|
||||
### 2. Vue machine — `ComponentItem.vue` / `PieceItem.vue`
|
||||
|
||||
Nouvelle section "Champs contextuels" affichée sous les custom fields existants :
|
||||
- Reçoit `contextCustomFields` et `contextCustomFieldValues` en props
|
||||
- Réutilise le composant `CustomFieldDisplay.vue` existant
|
||||
- Mode édition : sur blur/change, appel upsert via `CustomFieldValueController` avec le `machineComponentLinkId` ou `machinePieceLinkId`
|
||||
|
||||
### 3. Fiches autonomes pièce/composant
|
||||
|
||||
Filtrer les champs `machineContextOnly = true` pour ne pas les afficher :
|
||||
- Dans `useEntityCustomFields` : exclure ces champs du `displayedCustomFields`
|
||||
- Dans `useMachineDetailCustomFields` : séparer les champs normaux des champs contextuels
|
||||
|
||||
### 4. Transformation des données (`useMachineDetailCustomFields`)
|
||||
|
||||
`transformComponentCustomFields()` et `transformCustomFields()` :
|
||||
- Extraire `contextCustomFields` / `contextCustomFieldValues` depuis la réponse structure
|
||||
- Les passer en propriétés séparées sur l'objet transformé
|
||||
|
||||
## Tests
|
||||
|
||||
### Backend
|
||||
- Test unitaire : `CustomField` avec `machineContextOnly = true` est correctement sérialisé
|
||||
- Test API : upsert d'un `CustomFieldValue` avec `machineComponentLink` fonctionne
|
||||
- Test API : upsert d'un `CustomFieldValue` contextuel sans lien machine retourne une erreur
|
||||
- Test API : `/api/machines/{id}/structure` retourne les `contextCustomFields` et `contextCustomFieldValues`
|
||||
- Test API : clone machine copie les valeurs contextuelles
|
||||
|
||||
### Frontend
|
||||
- Typecheck : 0 erreurs après modifications
|
||||
- Vérification manuelle : les champs contextuels apparaissent dans la vue machine
|
||||
- Vérification manuelle : les champs contextuels n'apparaissent pas sur les fiches autonomes
|
||||
@@ -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)
|
||||
+26
-26
@@ -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"
|
||||
@@ -12,6 +12,7 @@
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@delete="$emit('delete')"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,5 +44,5 @@ defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
|
||||
</script>
|
||||
|
||||
@@ -13,226 +13,338 @@
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<!-- Component Header -->
|
||||
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @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 text-base-content truncate">
|
||||
{{ 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>
|
||||
<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>
|
||||
<button
|
||||
v-if="component.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', component.linkId, component.modelTypeId)"
|
||||
>
|
||||
À remplir
|
||||
</button>
|
||||
<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" 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"
|
||||
@field-blur="updateComponentCustomField"
|
||||
/>
|
||||
|
||||
<!-- 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"
|
||||
@@ -241,16 +353,20 @@
|
||||
@update="updatePiece"
|
||||
@edit="editPiece"
|
||||
@custom-field-update="updatePieceCustomField"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</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"
|
||||
@@ -260,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"
|
||||
@@ -276,6 +395,7 @@
|
||||
@update="$emit('update', $event)"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,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 {
|
||||
@@ -299,7 +421,6 @@ import {
|
||||
parseConstructeurLinksFromApi,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentIcon,
|
||||
@@ -307,7 +428,11 @@ import {
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
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 },
|
||||
@@ -317,7 +442,7 @@ const props = defineProps({
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
|
||||
|
||||
// --- Shared composables ---
|
||||
const {
|
||||
@@ -343,9 +468,111 @@ 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 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 = field.customFieldId
|
||||
const customFieldValueId = field.customFieldValueId
|
||||
if (!customFieldId && !customFieldValueId) return
|
||||
|
||||
field.value = value
|
||||
emit('custom-field-update', {
|
||||
entityType: 'machineComponentLink',
|
||||
entityId: linkId,
|
||||
fieldId: customFieldId,
|
||||
customFieldValueId,
|
||||
value: value ?? '',
|
||||
fieldName: field.name || 'Champ contextuel',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Document edit modal ---
|
||||
const editingDocument = ref(null)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
Retour au catalogue
|
||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
{{ backLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,7 +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 route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -34,18 +37,32 @@ const props = defineProps<{
|
||||
isEditMode: boolean
|
||||
canEdit: boolean
|
||||
backLink: string
|
||||
backLinkLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-edit': []
|
||||
}>()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
// Retour : on revient à l'URL précédente pour préserver l'état de la liste
|
||||
// (recherche, tri, pagination persistés en query params). Fallback sur le
|
||||
// backLink si pas d'historique applicatif (accès direct, refresh, lien partagé).
|
||||
const goBack = () => {
|
||||
if (route.query.from === 'machine' && route.query.machineId) {
|
||||
router.push(`/machine/${route.query.machineId}`)
|
||||
return
|
||||
}
|
||||
if (window.history.state?.back) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
else {
|
||||
navigateTo(props.backLink)
|
||||
}
|
||||
router.push(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,276 +13,351 @@
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||
<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">
|
||||
{{ pieceData.name }}
|
||||
<span
|
||||
v-if="displayQuantity > 1"
|
||||
class="text-sm font-normal text-base-content/60 ml-1"
|
||||
<!-- ═══ 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
|
||||
>
|
||||
×{{ 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" 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"
|
||||
@field-input="handleCustomFieldInput"
|
||||
@field-blur="handleCustomFieldBlur"
|
||||
/>
|
||||
|
||||
<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 {
|
||||
@@ -291,13 +366,13 @@ import {
|
||||
uniqueConstructeurIds,
|
||||
parseConstructeurLinksFromApi,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
resolveFieldId,
|
||||
resolveFieldReadOnly,
|
||||
} 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 },
|
||||
@@ -307,7 +382,7 @@ const props = defineProps({
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
|
||||
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete', 'fill-entity'])
|
||||
|
||||
// --- Local reactive data for editing ---
|
||||
const pieceData = reactive({
|
||||
@@ -361,9 +436,111 @@ 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 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 = field.customFieldId
|
||||
const customFieldValueId = field.customFieldValueId
|
||||
if (!customFieldId && !customFieldValueId) return
|
||||
|
||||
field.value = value
|
||||
emit('custom-field-update', {
|
||||
entityType: 'machinePieceLink',
|
||||
entityId: linkId,
|
||||
fieldId: customFieldId,
|
||||
customFieldValueId,
|
||||
value: value ?? '',
|
||||
fieldName: field.name || 'Champ contextuel',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Document edit modal ---
|
||||
const editingDocument = ref(null)
|
||||
@@ -485,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
|
||||
@@ -494,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,
|
||||
@@ -567,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
|
||||
@@ -124,6 +123,11 @@
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
|
||||
Contexte machine uniquement
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
@@ -161,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>
|
||||
@@ -121,6 +120,10 @@
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
|
||||
Obligatoire
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
|
||||
Contexte machine uniquement
|
||||
</div>
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="fields.length"
|
||||
class="mt-4 pt-4 border-t border-base-200"
|
||||
:class="containerClass"
|
||||
>
|
||||
<h5 class="text-sm font-medium text-base-content/80 mb-3">
|
||||
Champs personnalisés
|
||||
<h5 v-if="showHeader" class="text-sm font-medium text-base-content/80 mb-3">
|
||||
{{ title }}
|
||||
</h5>
|
||||
<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>
|
||||
|
||||
<!-- Mode édition -->
|
||||
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
|
||||
<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,25 +128,22 @@
|
||||
</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
|
||||
showHeader?: boolean
|
||||
withTopBorder?: boolean
|
||||
editable?: boolean
|
||||
emitBlur?: boolean
|
||||
}>()
|
||||
|
||||
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(() =>
|
||||
@@ -155,19 +152,37 @@ const layoutClass = computed(() =>
|
||||
: 'space-y-3',
|
||||
)
|
||||
|
||||
function onInput(field: any, value: string) {
|
||||
const title = computed(() => props.title ?? 'Champs personnalisés')
|
||||
const showHeader = computed(() => props.showHeader ?? true)
|
||||
const containerClass = computed(() =>
|
||||
props.withTopBorder === false
|
||||
? ''
|
||||
: 'mt-4 pt-4 border-t border-base-200',
|
||||
)
|
||||
const editable = computed(() => props.editable ?? true)
|
||||
const emitBlur = computed(() => props.emitBlur ?? true)
|
||||
|
||||
function isFieldEditable(field: CustomFieldInput) {
|
||||
return props.isEditMode && editable.value && !field.readOnly
|
||||
}
|
||||
|
||||
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)
|
||||
emit('field-blur', field)
|
||||
if (emitBlur.value) {
|
||||
emit('field-blur', field)
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur(field: any) {
|
||||
emit('field-blur', field)
|
||||
function onBlur(field: CustomFieldInput) {
|
||||
if (emitBlur.value) {
|
||||
emit('field-blur', field)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -5,6 +5,19 @@
|
||||
Ajouter une nouvelle machine
|
||||
</h3>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div v-if="errorMessage" class="alert alert-error mb-4" role="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
</svg>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -78,6 +91,7 @@ const props = defineProps<{
|
||||
sites: Array<{ id: string, name: string }>
|
||||
disabled: boolean
|
||||
preselectedSiteId?: string
|
||||
errorMessage?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<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].to" 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.to" 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">
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { useListQueryMemory } from '~/composables/useListQueryMemory'
|
||||
|
||||
interface Crumb {
|
||||
label: string
|
||||
to: RouteLocationRaw
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const { remember, recall } = useListQueryMemory()
|
||||
|
||||
// Routes-listes dont la recherche / tri / pagination doit survivre à une
|
||||
// navigation par fil d'Ariane ou menu (qui ne passe pas par l'historique).
|
||||
const LIST_PATHS = ['/machines', '/catalogues/composants', '/catalogues/pieces', '/catalogues/produits']
|
||||
|
||||
// On enregistre la query courante dès qu'on est sur une route-liste (et à chaque
|
||||
// changement de recherche/tri/pagination, qui modifie fullPath).
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (LIST_PATHS.includes(route.path)) remember(route.path, route.query)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Cible d'un crumb pointant vers une liste : on réinjecte la dernière query
|
||||
// mémorisée pour restaurer l'état, sinon chemin nu (liste neuve).
|
||||
const listTo = (path: string): RouteLocationRaw => {
|
||||
const query = recall(path)
|
||||
return query && Object.keys(query).length > 0 ? { path, query } : path
|
||||
}
|
||||
|
||||
const crumbs = computed<Crumb[]>(() => {
|
||||
const result: Crumb[] = [{ label: 'Accueil', to: '/' }]
|
||||
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', to: listTo('/machines') })
|
||||
result.push({ label: 'Machine', to: `/machine/${route.query.machineId}` })
|
||||
}
|
||||
|
||||
// Machines
|
||||
if (path === '/machines') {
|
||||
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||
} else if (path.startsWith('/machine/') && !route.query.from) {
|
||||
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||
result.push({ label: 'Machine', to: path })
|
||||
}
|
||||
|
||||
// Catalogs
|
||||
else if (path.startsWith('/catalogues/composants')) {
|
||||
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||
} else if (path.startsWith('/catalogues/pieces')) {
|
||||
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||
} else if (path.startsWith('/catalogues/produits')) {
|
||||
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||
}
|
||||
|
||||
// Entity detail pages (when NOT from machine context)
|
||||
else if (path.startsWith('/component/') && !route.query.from) {
|
||||
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||
result.push({ label: 'Composant', to: path })
|
||||
} else if (path.startsWith('/piece/') && !route.query.from) {
|
||||
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||
result.push({ label: 'Pièce', to: path })
|
||||
} else if (path.startsWith('/product/') && !route.query.from) {
|
||||
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||
result.push({ label: 'Produit', to: 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', to: path })
|
||||
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
|
||||
result.push({ label: 'Pièce', to: path })
|
||||
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
|
||||
result.push({ label: 'Produit', to: path })
|
||||
}
|
||||
|
||||
// Admin pages
|
||||
else if (path.startsWith('/sites')) {
|
||||
result.push({ label: 'Sites', to: '/sites' })
|
||||
} else if (path.startsWith('/constructeurs')) {
|
||||
result.push({ label: 'Fournisseurs', to: '/constructeurs' })
|
||||
} else if (path.startsWith('/activity-log')) {
|
||||
result.push({ label: 'Journal d\'activité', to: '/activity-log' })
|
||||
} else if (path.startsWith('/admin')) {
|
||||
result.push({ label: 'Administration', to: '/admin' })
|
||||
} else if (path.startsWith('/documents')) {
|
||||
result.push({ label: 'Documents', to: '/documents' })
|
||||
} else if (path.startsWith('/comments')) {
|
||||
result.push({ label: 'Commentaires', to: '/comments' })
|
||||
}
|
||||
|
||||
// Category pages
|
||||
else if (path.startsWith('/component-category')) {
|
||||
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||
result.push({ label: 'Catégorie', to: path })
|
||||
} else if (path.startsWith('/piece-category')) {
|
||||
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||
result.push({ label: 'Catégorie', to: path })
|
||||
} else if (path.startsWith('/product-category')) {
|
||||
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||
result.push({ label: 'Catégorie', to: path })
|
||||
}
|
||||
|
||||
// Create pages
|
||||
else if (path.startsWith('/pieces/create')) {
|
||||
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||
result.push({ label: 'Nouvelle pièce', to: path })
|
||||
} else if (path.startsWith('/component/create')) {
|
||||
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||
result.push({ label: 'Nouveau composant', to: path })
|
||||
} else if (path.startsWith('/product/create')) {
|
||||
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||
result.push({ label: 'Nouveau produit', to: path })
|
||||
} else if (path === '/machines/new') {
|
||||
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||
result.push({ label: 'Nouvelle machine', to: 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()
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTypeName && !selectedEntityId && !loadingEntities" class="bg-warning/10 border border-warning rounded-lg p-3 mb-4">
|
||||
<p class="text-sm text-warning font-medium">
|
||||
Aucun item sélectionné — la catégorie sera ajoutée avec le statut "À remplir".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary of selection -->
|
||||
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
|
||||
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
|
||||
@@ -64,10 +70,10 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedEntityId"
|
||||
:disabled="!selectedTypeId"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
Ajouter
|
||||
{{ selectedEntityId ? 'Ajouter' : 'Ajouter (catégorie seule)' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,11 +96,12 @@ type EntityKind = 'component' | 'piece' | 'product'
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
entityKind: EntityKind
|
||||
prefillTypeId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [entityId: string]
|
||||
confirm: [payload: { entityId?: string; modelTypeId: string; modelTypeName: string }]
|
||||
}>()
|
||||
|
||||
const selectedTypeId = ref('')
|
||||
@@ -166,6 +173,10 @@ watch(() => props.open, async (isOpen) => {
|
||||
if (props.entityKind === 'component') await loadComponentTypes()
|
||||
else if (props.entityKind === 'piece') await loadPieceTypes()
|
||||
else await loadProductTypes()
|
||||
|
||||
if (props.prefillTypeId) {
|
||||
selectedTypeId.value = props.prefillTypeId
|
||||
}
|
||||
})
|
||||
|
||||
// Load entities when type changes
|
||||
@@ -222,8 +233,12 @@ const handleClose = () => {
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedEntityId.value) return
|
||||
emit('confirm', selectedEntityId.value)
|
||||
if (!selectedTypeId.value) return
|
||||
emit('confirm', {
|
||||
entityId: selectedEntityId.value || undefined,
|
||||
modelTypeId: selectedTypeId.value,
|
||||
modelTypeName: selectedTypeName.value,
|
||||
})
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -28,12 +31,15 @@
|
||||
<div v-for="component in components" :key="component.id">
|
||||
<ComponentHierarchy
|
||||
:components="[component]"
|
||||
:is-edit-mode="false"
|
||||
:is-edit-mode="isEditMode"
|
||||
:show-delete="isEditMode"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@update="$emit('update-component', $event)"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@delete="$emit('remove-component', component.linkId || component.id)"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,5 +74,6 @@ defineEmits<{
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-component': []
|
||||
'remove-component': [linkId: string]
|
||||
'fill-entity': [linkId: string, modelTypeId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
Parc machines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -43,11 +49,28 @@
|
||||
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 { canEdit } = usePermissions()
|
||||
const router = useRouter()
|
||||
|
||||
defineProps<{
|
||||
// Retour : revient à l'URL précédente pour préserver la recherche/filtres du
|
||||
// parc machines (persistés en query params). Fallback vers /machines si pas
|
||||
// d'historique applicatif (accès direct, refresh, lien partagé).
|
||||
const goBack = () => {
|
||||
if (window.history.state?.back) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
router.push('/machines')
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
siteName?: string
|
||||
siteColor?: string
|
||||
reference?: string
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
@@ -56,12 +79,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"
|
||||
@@ -34,7 +37,9 @@
|
||||
:toggle-token="collapseToggleToken"
|
||||
@update="$emit('update-piece', $event)"
|
||||
@edit="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@delete="$emit('remove-piece', piece.linkId || piece.id)"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +70,9 @@ defineEmits<{
|
||||
'toggle-collapse': []
|
||||
'update-piece': [piece: any]
|
||||
'edit-piece': [piece: any]
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-piece': []
|
||||
'remove-piece': [linkId: string]
|
||||
'fill-entity': [linkId: string, modelTypeId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -23,14 +23,33 @@
|
||||
<div v-if="products.length" class="space-y-3">
|
||||
<div
|
||||
v-for="product in products"
|
||||
:key="product.id || product.name"
|
||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2"
|
||||
:key="product.id || product.linkId || product.name"
|
||||
class="rounded border p-3 text-sm space-y-2"
|
||||
:class="product.pendingEntity ? 'border-error bg-error/10' : 'border-base-200 bg-base-200/60'"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<p class="font-semibold text-base-content">
|
||||
{{ product.name }}
|
||||
<p class="font-semibold" :class="product.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||
<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
|
||||
v-if="product.pendingEntity"
|
||||
type="button"
|
||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
|
||||
title="Cliquer pour associer un item"
|
||||
@click="$emit('fill-entity', (product.linkId || product.id) as string, product.modelTypeId as string)"
|
||||
>
|
||||
À remplir
|
||||
</button>
|
||||
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
|
||||
{{ product.groupLabel }}
|
||||
</span>
|
||||
@@ -123,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 {
|
||||
@@ -132,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
|
||||
@@ -141,6 +163,9 @@ defineProps<{
|
||||
supplierLabel?: string | null
|
||||
priceLabel?: string | null
|
||||
groupLabel?: string
|
||||
pendingEntity?: boolean
|
||||
modelTypeId?: string | null
|
||||
modelType?: string | null
|
||||
documents?: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
@@ -156,6 +181,7 @@ defineProps<{
|
||||
defineEmits<{
|
||||
'add-product': []
|
||||
'remove-product': [linkId: string]
|
||||
'fill-entity': [linkId: string, modelTypeId: string]
|
||||
}>()
|
||||
|
||||
const previewDocument = ref<any>(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' },
|
||||
@@ -300,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
|
||||
limit.value = response.limit
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
|
||||
// Requête annulée volontairement (nouvelle recherche / démontage) : pas une
|
||||
// vraie erreur. On teste le signal car ofetch encapsule l'AbortError dans
|
||||
// une FetchError, donc error.name n'est pas fiable.
|
||||
if (controller.signal.aborted) return
|
||||
showError(extractErrorMessage(error))
|
||||
}
|
||||
finally {
|
||||
@@ -335,13 +319,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 +373,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(() => {})
|
||||
}
|
||||
@@ -288,14 +284,16 @@ export function useComponentEdit(componentId: string) {
|
||||
if (!structure?.pieces) return []
|
||||
return (structure.pieces as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.pieces[slot.slotId]
|
||||
const selectedPieceId = edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null)
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typePieceId: slot.typePieceId,
|
||||
selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null),
|
||||
selectedPieceId,
|
||||
selectedPieceName: slot.selectedPieceName ?? null,
|
||||
quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
|
||||
position: slot.position ?? i,
|
||||
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
|
||||
isEmpty: !selectedPieceId,
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -305,14 +303,16 @@ export function useComponentEdit(componentId: string) {
|
||||
if (!structure?.products) return []
|
||||
return (structure.products as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.products[slot.slotId]
|
||||
const selectedProductId = edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null)
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typeProductId: slot.typeProductId,
|
||||
selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null),
|
||||
selectedProductId,
|
||||
selectedProductName: slot.selectedProductName ?? null,
|
||||
familyCode: slot.familyCode,
|
||||
position: slot.position ?? i,
|
||||
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
|
||||
isEmpty: !selectedProductId,
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -322,15 +322,17 @@ export function useComponentEdit(componentId: string) {
|
||||
if (!structure?.subcomponents) return []
|
||||
return (structure.subcomponents as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.subcomponents[slot.slotId]
|
||||
const selectedComponentId = edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null)
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typeComposantId: slot.typeComposantId,
|
||||
selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null),
|
||||
selectedComponentId,
|
||||
selectedComponentName: slot.selectedComponentName ?? null,
|
||||
alias: slot.alias,
|
||||
familyCode: slot.familyCode,
|
||||
position: slot.position ?? i,
|
||||
label: slot.alias || `Sous-composant #${i + 1}`,
|
||||
isEmpty: !selectedComponentId,
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -386,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>[] = []
|
||||
@@ -493,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 },
|
||||
)
|
||||
@@ -559,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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
// CRUD operations
|
||||
const refreshDocuments = async () => {
|
||||
const e = entity()
|
||||
if (!e?.id || e._structurePiece) return
|
||||
// Pending / category-only nodes carry the link id (not a real entity id) and
|
||||
// have no backing piece/composant — never request documents for them.
|
||||
if (!e?.id || e._structurePiece || e.pendingEntity) return
|
||||
loadingDocuments.value = true
|
||||
try {
|
||||
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
||||
@@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
||||
}
|
||||
|
||||
const ensureDocumentsLoaded = async () => {
|
||||
if (documentsLoaded.value || !entity()?.id) return
|
||||
const e = entity()
|
||||
if (documentsLoaded.value || !e?.id || e.pendingEntity) return
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { reactive } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
// Singleton module-level : mémorise la dernière query (recherche / tri /
|
||||
// pagination / filtres) vue sur chaque route-liste. Permet aux navigations qui
|
||||
// ne passent PAS par l'historique du navigateur (fil d'Ariane, menu) de
|
||||
// restaurer l'état de la liste, là où router.back() le ferait pour le bouton
|
||||
// Retour. SPA only (SSR off) — pas de fuite d'état entre requêtes.
|
||||
const memory = reactive<Record<string, LocationQuery>>({})
|
||||
|
||||
export function useListQueryMemory() {
|
||||
const remember = (path: string, query: LocationQuery) => {
|
||||
memory[path] = { ...query }
|
||||
}
|
||||
const recall = (path: string): LocationQuery | undefined => memory[path]
|
||||
return { remember, recall }
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
|
||||
export function useMachineCreatePage() {
|
||||
@@ -18,7 +17,6 @@ export function useMachineCreatePage() {
|
||||
|
||||
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local state
|
||||
@@ -27,6 +25,9 @@ export function useMachineCreatePage() {
|
||||
const submitting = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
/** Persistent error shown inline in the form (e.g. duplicate name on the same site). */
|
||||
const createError = ref<string | null>(null)
|
||||
|
||||
const newMachine = reactive({
|
||||
name: '',
|
||||
siteId: '',
|
||||
@@ -41,8 +42,10 @@ export function useMachineCreatePage() {
|
||||
const finalizeMachineCreation = async () => {
|
||||
if (submitting.value) return
|
||||
|
||||
createError.value = null
|
||||
|
||||
if (!newMachine.name?.trim()) {
|
||||
toast.showError('Merci de renseigner un nom pour la machine')
|
||||
createError.value = 'Merci de renseigner un nom pour la machine.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,10 +83,10 @@ export function useMachineCreatePage() {
|
||||
await navigateTo('/machines')
|
||||
}
|
||||
} else if (result.error) {
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
||||
createError.value = humanizeError(result.error)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
||||
createError.value = humanizeError(error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -116,6 +119,7 @@ export function useMachineCreatePage() {
|
||||
machines,
|
||||
submitting,
|
||||
loading,
|
||||
createError,
|
||||
|
||||
// Actions
|
||||
finalizeMachineCreation,
|
||||
|
||||
@@ -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,
|
||||
@@ -44,6 +42,7 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineCustomFields = ref<AnyRecord[]>([])
|
||||
const pendingContextFieldUpdates = ref<AnyRecord[]>([])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
@@ -52,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(
|
||||
@@ -140,7 +106,9 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
|
||||
return {
|
||||
...normalizedPiece,
|
||||
customFields,
|
||||
customFields: customFields.filter((f: any) => !f.machineContextOnly && !f.customField?.machineContextOnly),
|
||||
contextCustomFields: piece.contextCustomFields ?? [],
|
||||
contextCustomFieldValues: piece.contextCustomFieldValues ?? [],
|
||||
documents: piece.documents || [],
|
||||
constructeurs: constructeursList,
|
||||
constructeur: constructeursList[0] || piece.constructeur || null,
|
||||
@@ -156,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
|
||||
@@ -240,7 +181,9 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
|
||||
return {
|
||||
...normalizedComponent,
|
||||
customFields,
|
||||
customFields: customFields.filter((f: any) => !f.machineContextOnly && !f.customField?.machineContextOnly),
|
||||
contextCustomFields: component.contextCustomFields ?? [],
|
||||
contextCustomFieldValues: component.contextCustomFieldValues ?? [],
|
||||
pieces: piecesTransformed,
|
||||
subComponents,
|
||||
documents: component.documents || [],
|
||||
@@ -266,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) => {
|
||||
@@ -297,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 {
|
||||
@@ -376,6 +310,83 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomFieldUpdate = async (fieldUpdate: AnyRecord) => {
|
||||
if (fieldUpdate?.entityType && fieldUpdate?.entityId) {
|
||||
queueContextFieldUpdate(fieldUpdate)
|
||||
return
|
||||
}
|
||||
|
||||
await updatePieceCustomField(fieldUpdate)
|
||||
}
|
||||
|
||||
const queueContextFieldUpdate = (fieldUpdate: AnyRecord) => {
|
||||
const entityType = fieldUpdate.entityType as string | undefined
|
||||
const entityId = fieldUpdate.entityId as string | undefined
|
||||
const fieldId = fieldUpdate.fieldId as string | undefined
|
||||
const customFieldValueId = fieldUpdate.customFieldValueId as string | undefined
|
||||
|
||||
if (!entityType || !entityId || (!fieldId && !customFieldValueId)) return
|
||||
|
||||
const nextUpdate = {
|
||||
entityType,
|
||||
entityId,
|
||||
fieldId,
|
||||
customFieldValueId,
|
||||
value: fieldUpdate.value ?? '',
|
||||
fieldName: fieldUpdate.fieldName ?? 'Champ contextuel',
|
||||
}
|
||||
|
||||
const existingIndex = pendingContextFieldUpdates.value.findIndex(
|
||||
(item) =>
|
||||
item.entityType === entityType &&
|
||||
item.entityId === entityId &&
|
||||
item.fieldId === fieldId &&
|
||||
item.customFieldValueId === customFieldValueId,
|
||||
)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
pendingContextFieldUpdates.value[existingIndex] = nextUpdate
|
||||
return
|
||||
}
|
||||
|
||||
pendingContextFieldUpdates.value.push(nextUpdate)
|
||||
}
|
||||
|
||||
const clearPendingContextFieldUpdates = () => {
|
||||
pendingContextFieldUpdates.value = []
|
||||
}
|
||||
|
||||
const saveAllContextCustomFields = async () => {
|
||||
const updates = pendingContextFieldUpdates.value.slice()
|
||||
if (!updates.length) return
|
||||
|
||||
try {
|
||||
for (const update of updates) {
|
||||
if (update.customFieldValueId) {
|
||||
await updateCustomFieldValueApi(update.customFieldValueId as string, {
|
||||
value: update.value ?? '',
|
||||
} as any)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!update.fieldId) {
|
||||
continue
|
||||
}
|
||||
|
||||
await upsertCustomFieldValue(
|
||||
update.fieldId as string,
|
||||
update.entityType as string,
|
||||
update.entityId as string,
|
||||
update.value ?? '',
|
||||
)
|
||||
}
|
||||
clearPendingContextFieldUpdates()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde batch des champs contextuels:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const saveAllMachineCustomFields = async () => {
|
||||
if (!machine.value) return
|
||||
|
||||
@@ -385,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) {
|
||||
@@ -431,6 +443,7 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
return {
|
||||
// State
|
||||
machineCustomFields,
|
||||
pendingContextFieldUpdates,
|
||||
|
||||
// Computed
|
||||
visibleMachineCustomFields,
|
||||
@@ -444,6 +457,10 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
handleCustomFieldUpdate,
|
||||
queueContextFieldUpdate,
|
||||
clearPendingContextFieldUpdates,
|
||||
saveAllMachineCustomFields,
|
||||
saveAllContextCustomFields,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -151,13 +150,18 @@ export function useMachineDetailData(machineId: string) {
|
||||
const {
|
||||
machineCustomFields,
|
||||
visibleMachineCustomFields,
|
||||
pendingContextFieldUpdates,
|
||||
transformCustomFields,
|
||||
transformComponentCustomFields,
|
||||
syncMachineCustomFields,
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
handleCustomFieldUpdate,
|
||||
queueContextFieldUpdate,
|
||||
clearPendingContextFieldUpdates,
|
||||
saveAllMachineCustomFields,
|
||||
saveAllContextCustomFields,
|
||||
} = useMachineDetailCustomFields({
|
||||
machine,
|
||||
isEditMode,
|
||||
@@ -193,6 +197,10 @@ export function useMachineDetailData(machineId: string) {
|
||||
removePieceLink,
|
||||
addProductLink,
|
||||
removeProductLink,
|
||||
addComponentLinkCategoryOnly,
|
||||
addPieceLinkCategoryOnly,
|
||||
addProductLinkCategoryOnly,
|
||||
fillEntityLink,
|
||||
} = hierarchy
|
||||
|
||||
// Keep the product links proxy in sync with the hierarchy's machineProductLinks
|
||||
@@ -218,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) {
|
||||
@@ -297,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()
|
||||
}
|
||||
@@ -329,10 +320,13 @@ export function useMachineDetailData(machineId: string) {
|
||||
// 2. Save all custom field values
|
||||
await saveAllMachineCustomFields()
|
||||
|
||||
// 3. Reload machine data to get fresh state
|
||||
// 3. Save contextual custom field values queued from piece/component inputs
|
||||
await saveAllContextCustomFields()
|
||||
|
||||
// 4. Reload machine data to get fresh state
|
||||
await loadMachineData()
|
||||
|
||||
// 4. Exit edit mode
|
||||
// 5. Exit edit mode
|
||||
isEditMode.value = false
|
||||
toast.showSuccess('Machine mise à jour avec succès')
|
||||
} catch (error) {
|
||||
@@ -346,6 +340,7 @@ export function useMachineDetailData(machineId: string) {
|
||||
const cancelEdition = () => {
|
||||
initMachineFields()
|
||||
syncMachineCustomFields()
|
||||
clearPendingContextFieldUpdates()
|
||||
constructeurLinks.value = originalConstructeurLinks.value.map(l => ({ ...l }))
|
||||
machineConstructeurIds.value = constructeurIdsFromLinks(constructeurLinks.value)
|
||||
isEditMode.value = false
|
||||
@@ -419,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 || [])
|
||||
@@ -434,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
|
||||
}
|
||||
|
||||
@@ -482,12 +473,12 @@ export function useMachineDetailData(machineId: string) {
|
||||
|
||||
// UI state
|
||||
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
||||
machineCustomFields, previewDocument, previewVisible,
|
||||
isEditMode, debug,
|
||||
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
||||
isEditMode,
|
||||
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
||||
|
||||
// Computed
|
||||
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap,
|
||||
componentTypeOptions, pieceTypeOptions,
|
||||
productInventory, productById, flattenedComponents, machinePieces,
|
||||
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
||||
|
||||
@@ -495,7 +486,8 @@ export function useMachineDetailData(machineId: string) {
|
||||
findProductById, resolveProductReference, getProductDisplay,
|
||||
initMachineFields, getMachineFieldId,
|
||||
syncMachineCustomFields, setMachineCustomFieldValue,
|
||||
updateMachineCustomField, updatePieceCustomField,
|
||||
updateMachineCustomField, updatePieceCustomField, handleCustomFieldUpdate,
|
||||
queueContextFieldUpdate, clearPendingContextFieldUpdates, saveAllContextCustomFields,
|
||||
refreshMachineDocuments, handleMachineFilesAdded, removeMachineDocument,
|
||||
openPreview, closePreview,
|
||||
updateMachineInfo, updateComponent, updatePieceFromComponent,
|
||||
@@ -511,6 +503,8 @@ export function useMachineDetailData(machineId: string) {
|
||||
loadMachineData, loadInitialData,
|
||||
addComponentLink, removeComponentLink, addPieceLink, removePieceLink,
|
||||
addProductLink, removeProductLink, reloadMachineStructure,
|
||||
addComponentLinkCategoryOnly, addPieceLinkCategoryOnly,
|
||||
addProductLinkCategoryOnly, fillEntityLink,
|
||||
|
||||
// External
|
||||
constructeurs, loadProducts, updateMachineStructure, toast,
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
|
||||
syncMachineCustomFields,
|
||||
} = deps
|
||||
|
||||
const { get, post: apiPost, delete: apiDel } = useApi()
|
||||
const { get, post: apiPost, delete: apiDel, patch: apiPatch } = useApi()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -263,6 +263,69 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
|
||||
return result
|
||||
}
|
||||
|
||||
const addComponentLinkCategoryOnly = async (modelTypeId: string) => {
|
||||
const result: any = await apiPost('/machine_component_links', {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
modelType: `/api/model_types/${modelTypeId}`,
|
||||
})
|
||||
if (result.success) {
|
||||
toast.showSuccess('Catégorie ajoutée — item à remplir')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout')
|
||||
}
|
||||
}
|
||||
|
||||
const addPieceLinkCategoryOnly = async (modelTypeId: string) => {
|
||||
const result: any = await apiPost('/machine_piece_links', {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
modelType: `/api/model_types/${modelTypeId}`,
|
||||
})
|
||||
if (result.success) {
|
||||
toast.showSuccess('Catégorie ajoutée — item à remplir')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout')
|
||||
}
|
||||
}
|
||||
|
||||
const addProductLinkCategoryOnly = async (modelTypeId: string) => {
|
||||
const result: any = await apiPost('/machine_product_links', {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
modelType: `/api/model_types/${modelTypeId}`,
|
||||
})
|
||||
if (result.success) {
|
||||
toast.showSuccess('Catégorie ajoutée — item à remplir')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout')
|
||||
}
|
||||
}
|
||||
|
||||
const fillEntityLink = async (linkId: string, entityId: string, entityKind: string) => {
|
||||
let endpoint = ''
|
||||
let payload: Record<string, string> = {}
|
||||
|
||||
if (entityKind === 'component') {
|
||||
endpoint = `/machine_component_links/${linkId}`
|
||||
payload = { composant: `/api/composants/${entityId}` }
|
||||
} else if (entityKind === 'piece') {
|
||||
endpoint = `/machine_piece_links/${linkId}`
|
||||
payload = { piece: `/api/pieces/${entityId}` }
|
||||
} else {
|
||||
endpoint = `/machine_product_links/${linkId}`
|
||||
payload = { product: `/api/products/${entityId}` }
|
||||
}
|
||||
|
||||
const result: any = await apiPatch(endpoint, payload)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Item associé avec succès')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'association')
|
||||
}
|
||||
}
|
||||
|
||||
const removeProductLink = async (linkId: string) => {
|
||||
const result: any = await apiDel(`/machine_product_links/${linkId}`)
|
||||
if (result.success) {
|
||||
@@ -301,6 +364,10 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
|
||||
addPieceLink,
|
||||
removePieceLink,
|
||||
addProductLink,
|
||||
addComponentLinkCategoryOnly,
|
||||
addPieceLinkCategoryOnly,
|
||||
addProductLinkCategoryOnly,
|
||||
fillEntityLink,
|
||||
removeProductLink,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
|
||||
return {
|
||||
id: (resolved?.id as string) || productId || null,
|
||||
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
|
||||
name: (resolved?.name as string) || 'Produit inconnu',
|
||||
name: (resolved?.name as string) || (link.modelType as AnyRecord)?.name as string || 'Produit inconnu',
|
||||
reference: (resolved?.reference as string) || null,
|
||||
supplierLabel: resolvedConstructeurs.length
|
||||
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
|
||||
@@ -111,6 +111,9 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
|
||||
priceLabel: resolved ? getProductPriceLabel(resolved) : null,
|
||||
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
|
||||
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
|
||||
pendingEntity: (link.pendingEntity as boolean) || false,
|
||||
modelTypeId: (link.modelTypeId as string) || null,
|
||||
modelType: (link.modelType as string) || null,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,11 +73,11 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
const updateMachineInfo = async () => {
|
||||
if (!machine.value) return
|
||||
try {
|
||||
const result: any = await updateMachineApi(machine.value.id as string, {
|
||||
name: machineName.value,
|
||||
reference: machineReference.value,
|
||||
siteId: machineSiteId.value || undefined,
|
||||
} as any)
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (machineName.value !== machine.value.name) payload.name = machineName.value
|
||||
if (machineReference.value !== machine.value.reference) payload.reference = machineReference.value
|
||||
if ((machineSiteId.value || undefined) !== ((machine.value.siteId as string) || (machine.value.site as any)?.id || undefined)) payload.siteId = machineSiteId.value || undefined
|
||||
const result: any = await updateMachineApi(machine.value.id as string, payload as any)
|
||||
if (result.success) {
|
||||
const machinePayload =
|
||||
result.data?.machine && typeof result.data.machine === 'object'
|
||||
|
||||
@@ -150,6 +150,30 @@ export const buildMachineHierarchyFromLinks = (
|
||||
const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => {
|
||||
if (!link || typeof link !== 'object') return null
|
||||
|
||||
// Category-only link (no entity yet)
|
||||
if (link.pendingEntity || (!link.piece && !link.pieceId)) {
|
||||
const machinePieceLinkId = normalizePieceLinkId(link)
|
||||
const mt = (link.modelType || null) as AnyRecord | null
|
||||
return {
|
||||
id: machinePieceLinkId || `pending-${link.id}`,
|
||||
linkId: machinePieceLinkId,
|
||||
name: mt?.name || 'Catégorie sans item',
|
||||
reference: null,
|
||||
prix: null,
|
||||
pendingEntity: true,
|
||||
modelTypeId: link.modelTypeId || mt?.id || null,
|
||||
modelType: mt,
|
||||
pieceId: null,
|
||||
constructeurs: [],
|
||||
documents: [],
|
||||
customFields: [],
|
||||
parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null,
|
||||
parentComponentName,
|
||||
machinePieceLinkId,
|
||||
quantity: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
|
||||
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
|
||||
|
||||
@@ -184,6 +208,8 @@ export const buildMachineHierarchyFromLinks = (
|
||||
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
|
||||
definition: appliedPiece.definition || originalPiece?.definition || {},
|
||||
customFields: appliedPiece.customFields || [],
|
||||
contextCustomFields: link.contextCustomFields || [],
|
||||
contextCustomFieldValues: link.contextCustomFieldValues || [],
|
||||
}
|
||||
|
||||
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
|
||||
@@ -205,6 +231,35 @@ export const buildMachineHierarchyFromLinks = (
|
||||
const createComponentNode = (link: AnyRecord): AnyRecord | null => {
|
||||
if (!link || typeof link !== 'object') return null
|
||||
|
||||
// Category-only link (no entity yet)
|
||||
if (link.pendingEntity || (!link.composant && !link.composantId)) {
|
||||
const machineComponentLinkId = normalizeComponentLinkId(link)
|
||||
const mt = (link.modelType || null) as AnyRecord | null
|
||||
return {
|
||||
id: machineComponentLinkId || `pending-${link.id}`,
|
||||
linkId: machineComponentLinkId,
|
||||
name: mt?.name || 'Catégorie sans item',
|
||||
reference: null,
|
||||
prix: null,
|
||||
pendingEntity: true,
|
||||
modelTypeId: link.modelTypeId || mt?.id || null,
|
||||
modelType: mt,
|
||||
composantId: null,
|
||||
composant: null,
|
||||
constructeurs: [],
|
||||
documents: [],
|
||||
customFields: [],
|
||||
customFieldValues: [],
|
||||
subComponents: [],
|
||||
pieces: [],
|
||||
overrides: null,
|
||||
parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null,
|
||||
machineComponentLinkId,
|
||||
childLinks: [],
|
||||
pieceLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
|
||||
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
|
||||
|
||||
@@ -227,11 +282,13 @@ export const buildMachineHierarchyFromLinks = (
|
||||
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
|
||||
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
|
||||
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
|
||||
const isEmpty = !resolved
|
||||
const typePieceName = (resolved?.typePiece as AnyRecord)?.name || (definition.typePiece as AnyRecord)?.name || (def.typePiece as AnyRecord)?.name || null
|
||||
return {
|
||||
...(resolved || {}),
|
||||
id: resolved?.id || `structure-piece-${composantId}-${index}`,
|
||||
pieceId: resolved?.id || null,
|
||||
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`,
|
||||
name: resolved?.name || definition.role || definition.name || def.role || def.name || (typePieceName ? `${typePieceName}` : `Pièce ${index + 1}`),
|
||||
reference: resolved?.reference || definition.reference || def.reference || null,
|
||||
prix: resolved?.prix ?? null,
|
||||
constructeurs: resolved?.constructeurs || [],
|
||||
@@ -243,6 +300,7 @@ export const buildMachineHierarchyFromLinks = (
|
||||
parentComponentLinkId: machineComponentLinkId,
|
||||
parentComponentName: componentName,
|
||||
_structurePiece: true,
|
||||
_emptySlot: isEmpty,
|
||||
}
|
||||
}) as AnyRecord[]
|
||||
|
||||
@@ -279,6 +337,8 @@ export const buildMachineHierarchyFromLinks = (
|
||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
||||
definition: appliedComponent.definition || originalComponent?.definition || {},
|
||||
customFields: appliedComponent.customFields || [],
|
||||
contextCustomFields: link.contextCustomFields || [],
|
||||
contextCustomFieldValues: link.contextCustomFieldValues || [],
|
||||
pieces,
|
||||
subComponents,
|
||||
subcomponents: subComponents,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -88,6 +88,7 @@ const toEditorField = (
|
||||
...(typeof input?.id === 'string' && input.id ? { id: input.id } : {}),
|
||||
...(typeof input?.customFieldId === 'string' && input.customFieldId ? { customFieldId: input.customFieldId } : {}),
|
||||
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
|
||||
machineContextOnly: Boolean(input?.machineContextOnly),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +163,7 @@ const buildPayload = (
|
||||
type,
|
||||
required,
|
||||
orderIndex: index,
|
||||
machineContextOnly: Boolean(field.machineContextOnly),
|
||||
}
|
||||
|
||||
if (field.id) {
|
||||
@@ -286,6 +288,7 @@ export function usePieceStructureEditorLogic(deps: Deps) {
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
machineContextOnly: false,
|
||||
orderIndex,
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -56,6 +56,7 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
|
||||
required: false,
|
||||
optionsText: '',
|
||||
options: [],
|
||||
machineContextOnly: false,
|
||||
orderIndex: nextIndex,
|
||||
})
|
||||
reindexCustomFields()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,282 @@
|
||||
<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 { buildDeleteMessageWithUsage, type UsageInfo } 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 api = useApi()
|
||||
|
||||
const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||
const pieceName = piece?.name || 'cette pièce'
|
||||
|
||||
let usage: UsageInfo = {}
|
||||
try {
|
||||
const result = await api.get(`/pieces/${piece.id}/used-in`)
|
||||
if (result.success && result.data) {
|
||||
usage = {
|
||||
machines: result.data.machines ?? [],
|
||||
composants: result.data.composants ?? [],
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Impossible de récupérer les usages de la pièce avant suppression :', error)
|
||||
}
|
||||
|
||||
const message = buildDeleteMessageWithUsage(pieceName, 'Cette pièce', usage)
|
||||
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>
|
||||
@@ -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">{{ 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">{{ 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">{{ 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,378 +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">{{ 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 bg-base-200 flex items-center gap-2">
|
||||
{{ slot.selectedPieceName || '— Non sélectionné' }}
|
||||
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
|
||||
</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">{{ 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 bg-base-200 flex items-center">
|
||||
{{ slot.selectedProductName || '— Non sélectionné' }}
|
||||
<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">{{ 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 bg-base-200 flex items-center">
|
||||
{{ slot.selectedComponentName || '— Non sélectionné' }}
|
||||
</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
|
||||
@@ -420,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>
|
||||
@@ -451,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,
|
||||
@@ -467,6 +517,7 @@ const {
|
||||
constructeurLinks,
|
||||
constructeurIdsFromForm,
|
||||
customFieldInputs,
|
||||
requiredCustomFieldsFilled,
|
||||
historyFieldLabels,
|
||||
canSubmit,
|
||||
componentTypeList,
|
||||
@@ -494,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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user