Compare commits
168 Commits
v1.9.5
...
d3f269452c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
12c2b1e1b3 | ||
|
|
b92c09cf55 | ||
| 18cb9d5d80 | |||
|
|
4ba134dd69 | ||
|
|
5e7a744151 | ||
|
|
044b64152c | ||
|
|
4de3ffa0e0 | ||
|
|
5bdf578de9 | ||
|
|
bc1b757a96 | ||
|
|
24b664e85b | ||
|
|
8565e68062 | ||
|
|
a8a95b16a9 |
@@ -2,10 +2,10 @@
|
||||
.gitea
|
||||
.env.local
|
||||
.env.test
|
||||
docker/
|
||||
deploy/docker/docker-compose.prod.yml
|
||||
deploy/docker/deploy.sh
|
||||
deploy/docker/.env.example
|
||||
infra/dev/
|
||||
infra/prod/docker-compose.yml
|
||||
infra/prod/deploy.sh.example
|
||||
infra/prod/.env.example
|
||||
frontend/node_modules
|
||||
frontend/.nuxt
|
||||
frontend/.output
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
token: ${{ secrets.REGISTRY_TOKEN }}
|
||||
persist-credentials: true
|
||||
|
||||
- name: Create next tag from VERSION
|
||||
- name: Create next tag from config/version.yaml
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -28,18 +28,18 @@ jobs:
|
||||
fi
|
||||
|
||||
changed_version=false
|
||||
if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^VERSION$'; then
|
||||
if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^config/version\.yaml$'; then
|
||||
changed_version=true
|
||||
fi
|
||||
|
||||
read_version() {
|
||||
cat VERSION | tr -d '[:space:]'
|
||||
awk -F': *' '/app\.version:/{print $2}' config/version.yaml | tr -d '[:space:]' | tr -d "'\""
|
||||
}
|
||||
|
||||
if $changed_version; then
|
||||
version="$(read_version)"
|
||||
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Invalid version in VERSION: $version" >&2
|
||||
echo "Invalid version in version.yaml: $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
@@ -52,10 +52,10 @@ jobs:
|
||||
version="${major}.${minor}.$((patch + 1))"
|
||||
fi
|
||||
|
||||
echo "$version" > VERSION
|
||||
printf "parameters:\\n app.version: '%s'\\n" "$version" > config/version.yaml
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "gitea-actions@local"
|
||||
git add VERSION
|
||||
git add config/version.yaml
|
||||
git commit -m "chore : bump version to v$version" || true
|
||||
git push origin develop || true
|
||||
fi
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build \
|
||||
-f deploy/docker/Dockerfile.prod \
|
||||
-f infra/prod/Dockerfile \
|
||||
-t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \
|
||||
-t gitea.malio.fr/malio-dev/inventory:latest \
|
||||
.
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
name: Build Release Artefact
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.4"
|
||||
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install backend deps (prod)
|
||||
env:
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: "0"
|
||||
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||
|
||||
- name: Build frontend (static)
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE_URL=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
||||
test -f .output/public/index.html
|
||||
|
||||
- name: Build artefact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/inventory-${GITHUB_REF_NAME}.tar.gz" \
|
||||
.env \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
public \
|
||||
src \
|
||||
templates \
|
||||
vendor \
|
||||
composer.json \
|
||||
composer.lock \
|
||||
symfony.lock \
|
||||
VERSION \
|
||||
frontend/.output
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/inventory-${{ github.ref_name }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -20,7 +20,7 @@
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
###> docker ###
|
||||
docker/.env.docker.local
|
||||
infra/dev/.env.docker.local
|
||||
###< docker ###
|
||||
###> migration archives ###
|
||||
/_archives/
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
"X-Profile-Id": "admin-default-profile",
|
||||
"X-Profile-Password": "A123"
|
||||
}
|
||||
},
|
||||
"lesstime": {
|
||||
"type": "http",
|
||||
"url": "http://project.malio-dev.fr/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer b355c6cbf27d2a86d7eba1c3132c99bb3133f94cfd9e9243ffcc3c5ae1dc82c8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -81,6 +81,11 @@ make fixtures-reset # Reset DB + recharger fixtures
|
||||
make import-data # Importer les dumps SQL normalisés
|
||||
make cache-clear # Clear cache Symfony
|
||||
|
||||
# Import fournisseurs (customer.json → Constructeur + ConstructeurCategorie + ConstructeurTelephone)
|
||||
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run (par défaut)
|
||||
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
|
||||
# Non destructif : find-or-create par nom normalisé, ne change jamais un ID existant, n'ajoute que les téléphones/catégories manquants
|
||||
|
||||
# Release
|
||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
||||
```
|
||||
@@ -116,7 +121,9 @@ Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
## Architecture Backend
|
||||
|
||||
### Entités Principales
|
||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `ConstructeurCategorie`, `ConstructeurTelephone`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
||||
|
||||
> **Constructeur (Fournisseur)** : possède `name`, `email`, une collection `telephones` (1-N → `ConstructeurTelephone`, cascade/orphanRemoval) et `categories` (M2M → `ConstructeurCategorie`, table `constructeur_categories`). Sérialisation API Platform via les groupes `constructeur:read` / `constructeur:write` (téléphones & catégories embarqués). ⚠️ L'adder M2M s'appelle `addCategory()`/`removeCategory()` (l'inflector singularise `categories` → `category`), pas `addCategorie`. `ConstructeurCategorie` et `ConstructeurTelephone` sont aussi des `ApiResource` à part entière (`/api/constructeur_categories`, `/api/constructeur_telephones`).
|
||||
|
||||
#### Entités de normalisation (slots & skeleton requirements)
|
||||
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
|
||||
@@ -199,6 +206,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
|
||||
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
|
||||
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
|
||||
- **⚠️ Préfixe `/api`** : `useApi()` **prepend déjà** `apiBaseUrl` (= `/api` par défaut, cf. `nuxt.config.ts`). Les appels doivent donc utiliser des chemins **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` et **PAS** `api.get('/api/custom-fields/names')` (sinon 404 sur `/api/api/...`).
|
||||
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
|
||||
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
|
||||
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
|
||||
@@ -256,7 +264,7 @@ make test-setup # Créer/mettre à jour le schéma test
|
||||
### Pattern de test
|
||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
||||
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`, `createConstructeurCategorie()`, `createConstructeurTelephone()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
||||
|
||||
## URLs Locales
|
||||
@@ -264,3 +272,12 @@ make test-setup # Créer/mettre à jour le schéma test
|
||||
- Nuxt dev : `http://localhost:3001`
|
||||
- Adminer (PG) : `http://localhost:5050`
|
||||
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
||||
|
||||
## Delegation Codex
|
||||
|
||||
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||
|
||||
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||
|
||||
C'est le meilleur ratio qualite/credits.
|
||||
|
||||
@@ -57,7 +57,7 @@ make start
|
||||
make install
|
||||
```
|
||||
|
||||
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`.
|
||||
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `infra/dev/.env.docker.local`.
|
||||
|
||||
### Que fait `make install` ?
|
||||
|
||||
@@ -254,7 +254,7 @@ Configuration PhpStorm / VSCode :
|
||||
- **Port** : `8081`
|
||||
- **Path mapping** : racine du projet → `/var/www/html`
|
||||
|
||||
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `docker/.env.docker.local` avec votre IP locale.
|
||||
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `infra/dev/.env.docker.local` avec votre IP locale.
|
||||
|
||||
## Git
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0.2",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/rate-limiter": "8.0.*",
|
||||
|
||||
434
composer.lock
generated
434
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "2db01f705a09cf38007a2baa3b078e49",
|
||||
"content-hash": "5c54b1589d9e815f4c9b7e5e1d2d69c7",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -2437,6 +2437,109 @@
|
||||
},
|
||||
"time": "2026-02-23T21:42:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Seldaek/monolog.git",
|
||||
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
||||
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"psr/log": "^2.0 || ^3.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/log-implementation": "3.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"aws/aws-sdk-php": "^3.0",
|
||||
"doctrine/couchdb": "~1.0@dev",
|
||||
"elasticsearch/elasticsearch": "^7 || ^8",
|
||||
"ext-json": "*",
|
||||
"graylog2/gelf-php": "^1.4.2 || ^2.0",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/psr7": "^2.2",
|
||||
"mongodb/mongodb": "^1.8 || ^2.0",
|
||||
"php-amqplib/php-amqplib": "~2.4 || ^3",
|
||||
"php-console/php-console": "^3.1.8",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpstan/phpstan-deprecation-rules": "^2",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
|
||||
"predis/predis": "^1.1 || ^2",
|
||||
"rollbar/rollbar": "^4.0",
|
||||
"ruflin/elastica": "^7 || ^8",
|
||||
"symfony/mailer": "^5.4 || ^6",
|
||||
"symfony/mime": "^5.4 || ^6"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
|
||||
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
|
||||
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
|
||||
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
|
||||
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
|
||||
"ext-mbstring": "Allow to work properly with unicode symbols",
|
||||
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
|
||||
"ext-openssl": "Required to send log messages using SSL",
|
||||
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
|
||||
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
|
||||
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
|
||||
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
|
||||
"rollbar/rollbar": "Allow sending log messages to Rollbar",
|
||||
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Monolog\\": "src/Monolog"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "https://seld.be"
|
||||
}
|
||||
],
|
||||
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
|
||||
"homepage": "https://github.com/Seldaek/monolog",
|
||||
"keywords": [
|
||||
"log",
|
||||
"logging",
|
||||
"psr-3"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Seldaek/monolog/issues",
|
||||
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Seldaek",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-02T08:56:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nelmio/cors-bundle",
|
||||
"version": "2.6.0",
|
||||
@@ -5341,6 +5444,248 @@
|
||||
],
|
||||
"time": "2026-03-04T16:39:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mime.git",
|
||||
"reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mime/zipball/ddff21f14c7ce04b98101b399a9463dce8b0ce66",
|
||||
"reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/polyfill-intl-idn": "^1.10",
|
||||
"symfony/polyfill-mbstring": "^1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"egulias/email-validator": "~3.0.0",
|
||||
"phpdocumentor/reflection-docblock": "<5.2|>=7",
|
||||
"phpdocumentor/type-resolver": "<1.5.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"egulias/email-validator": "^2.1.10|^3.1|^4",
|
||||
"league/html-to-markdown": "^5.0",
|
||||
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/property-access": "^7.4|^8.0",
|
||||
"symfony/property-info": "^7.4|^8.0",
|
||||
"symfony/serializer": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Mime\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Allows manipulating MIME messages",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"mime",
|
||||
"mime-type"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mime/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/monolog-bridge",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/monolog-bridge.git",
|
||||
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c6efdcbd5cc17cf7618fb4447053b792df6ae724",
|
||||
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"monolog/monolog": "^3",
|
||||
"php": ">=8.4",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^7.4|^8.0",
|
||||
"symfony/http-client": "^7.4|^8.0",
|
||||
"symfony/mailer": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/mime": "^7.4|^8.0",
|
||||
"symfony/security-core": "^7.4|^8.0",
|
||||
"symfony/var-dumper": "^7.4|^8.0"
|
||||
},
|
||||
"type": "symfony-bridge",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bridge\\Monolog\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides integration for Monolog with various Symfony components",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/monolog-bridge/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/monolog-bundle",
|
||||
"version": "v4.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/monolog-bundle.git",
|
||||
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/c012c6aba13129eb02aa7dd61e66e720911d8598",
|
||||
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.0",
|
||||
"monolog/monolog": "^3.5",
|
||||
"php": ">=8.2",
|
||||
"symfony/config": "^7.3 || ^8.0",
|
||||
"symfony/dependency-injection": "^7.3 || ^8.0",
|
||||
"symfony/http-kernel": "^7.3 || ^8.0",
|
||||
"symfony/monolog-bridge": "^7.3 || ^8.0",
|
||||
"symfony/polyfill-php84": "^1.30"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5.41 || ^12.3",
|
||||
"symfony/console": "^7.3 || ^8.0",
|
||||
"symfony/yaml": "^7.3 || ^8.0"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bundle\\MonologBundle\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony MonologBundle",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"log",
|
||||
"logging"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/symfony/monolog-bundle/issues",
|
||||
"source": "https://github.com/symfony/monolog-bundle/tree/v4.0.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-02T18:27:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/options-resolver",
|
||||
"version": "v8.0.0",
|
||||
@@ -5567,6 +5912,93 @@
|
||||
],
|
||||
"time": "2025-06-27T09:58:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-idn",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-intl-idn.git",
|
||||
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"symfony/polyfill-intl-normalizer": "^1.10"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-intl": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Intl\\Idn\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Laurent Bassin",
|
||||
"email": "laurent@bassin.info"
|
||||
},
|
||||
{
|
||||
"name": "Trevor Rowbotham",
|
||||
"email": "trevor.rowbotham@pm.me"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"idn",
|
||||
"intl",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-10T14:38:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-normalizer",
|
||||
"version": "v1.33.0",
|
||||
|
||||
@@ -9,6 +9,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Symfony\AI\McpBundle\McpBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
|
||||
@@ -22,4 +23,5 @@ return [
|
||||
ApiPlatformBundle::class => ['all' => true],
|
||||
DAMADoctrineTestBundle::class => ['test' => true],
|
||||
McpBundle::class => ['all' => true],
|
||||
MonologBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
api_platform:
|
||||
title: Inventory API
|
||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||
version: 1.9.1
|
||||
version: 1.9.6
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
|
||||
56
config/packages/monolog.yaml
Normal file
56
config/packages/monolog.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
monolog:
|
||||
channels:
|
||||
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
max_files: 7
|
||||
channels: ["!event"]
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!console"]
|
||||
|
||||
when@test:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!event"]
|
||||
nested:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
|
||||
when@prod:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!deprecation"]
|
||||
buffer_size: 50
|
||||
nested:
|
||||
type: rotating_file
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
max_files: 30
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine"]
|
||||
deprecation:
|
||||
type: rotating_file
|
||||
channels: [deprecation]
|
||||
path: "%kernel.logs_dir%/deprecations.log"
|
||||
max_files: 7
|
||||
@@ -8,3 +8,15 @@ framework:
|
||||
policy: sliding_window
|
||||
limit: 5
|
||||
interval: '1 minute'
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
rate_limiter:
|
||||
mcp_auth:
|
||||
policy: sliding_window
|
||||
limit: 10000
|
||||
interval: '1 minute'
|
||||
login:
|
||||
policy: sliding_window
|
||||
limit: 10000
|
||||
interval: '1 minute'
|
||||
|
||||
@@ -55,6 +55,7 @@ security:
|
||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/maintenance/check$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/_mcp, roles: ROLE_USER }
|
||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||
|
||||
@@ -69,3 +69,8 @@ when@test:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\SkeletonStructureService:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
2
config/version.yaml
Normal file
2
config/version.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.36'
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export INVENTORY_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying inventory:${TAG}..."
|
||||
|
||||
echo "==> Pulling image..."
|
||||
sudo docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
sudo docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Running migrations..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
VERSION=$(sudo docker compose exec -T app cat VERSION)
|
||||
echo "==> Deployed v${VERSION}"
|
||||
@@ -1,13 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name inventory.malio-dev.fr;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ services:
|
||||
web:
|
||||
container_name: php-${DOCKER_APP_NAME}-apache
|
||||
build:
|
||||
context: ./docker/php
|
||||
context: ./infra/dev
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
||||
@@ -20,9 +20,9 @@ services:
|
||||
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
||||
- ~/.config:/var/www/.config # Pour la config de yarn
|
||||
- ~/.composer:/var/www/.composer # Pour la config de composer
|
||||
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./docker/php/config/vhost.conf:/etc/apache2/sites-available/000-default.conf
|
||||
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./infra/dev/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./infra/dev/vhost.conf:/etc/apache2/sites-available/000-default.conf
|
||||
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./LOG:/var/www/html/LOG
|
||||
- ./LOG/logs_apache:/var/log/apache2/
|
||||
extra_hosts:
|
||||
|
||||
1182
docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md
Normal file
1182
docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md
Normal file
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`
|
||||
148
docs/superpowers/session-2026-04-04-ux-overhaul.md
Normal file
148
docs/superpowers/session-2026-04-04-ux-overhaul.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Session 04-05 avril 2026 — Refonte UX/UI complète Inventory
|
||||
|
||||
## Contexte
|
||||
L'utilisateur (gestionnaire) remonte que les utilisateurs novices se perdent dans l'app Inventory (gestion d'inventaire industriel : machines, composants, pièces, produits). Ils découvrent le domaine ET l'app en même temps, remplissent les machines depuis de la documentation papier/PDF.
|
||||
|
||||
## Ce qui a été fait
|
||||
|
||||
### 1. Analyse UX/UI complète
|
||||
- Exploration en profondeur des 65+ composants, toutes les pages, composables et patterns
|
||||
- Diagnostic : navigation top-down uniquement, pas de liens inverses, pas de breadcrumbs, navbar mélange tout, pages trop longues, mode lecture ressemble à un formulaire disabled
|
||||
- Identification de 23 améliorations organisées en 4 phases
|
||||
|
||||
### 2. Spec rédigée
|
||||
**Fichier :** `docs/superpowers/specs/2026-04-04-ux-overhaul-design.md`
|
||||
|
||||
23 sections couvrant :
|
||||
- Réorganisation navbar par domaine métier
|
||||
- Breadcrumbs contextuels
|
||||
- Liaisons inverses "Utilisé dans"
|
||||
- Liens cliquables dans la hiérarchie machine
|
||||
- Système d'onglets partagé (machine + composant + pièce + produit)
|
||||
- Pages catalogue unifiées (catalogue + catégories en onglets)
|
||||
- Recherche globale (**retirée** à la demande de l'utilisateur)
|
||||
- Raccourcis clavier (**retirés** à la demande)
|
||||
- Mode lecture texte brut, empty states, toasts, responsive, etc.
|
||||
|
||||
### 3. Phase 1 — Quick wins (9 améliorations, 0 backend)
|
||||
|
||||
**Plan :** `docs/superpowers/plans/2026-04-04-ux-quick-wins.md`
|
||||
|
||||
| Changement | Fichiers modifiés |
|
||||
|-----------|-------------------|
|
||||
| Liens cliquables dans hiérarchie machine | ComponentItem, PieceItem, MachineProductsCard |
|
||||
| Site → machines (badge cliquable) | SiteCard, index.vue |
|
||||
| Retour contextuel (NuxtLink au lieu de router.back) | DetailHeader |
|
||||
| Confirmations sur toutes les suppressions | CommentSection, machine/[id].vue |
|
||||
| Header sticky composants expanded | ComponentItem |
|
||||
| DataTable fixedLayout opt-in + minWidth | DataTable.vue, dataTable.ts |
|
||||
| Mode lecture texte brut (26 div-inputs → `<p>`) | MachineInfoCard, 3 pages détail |
|
||||
| Compteurs titres sections machine | MachineComponentsCard, MachinePiecesCard, MachineDocumentsCard |
|
||||
| Cohérence fiches (liens catégorie + EntityVersionList) | 3 pages détail entité |
|
||||
|
||||
**Review Phase 1 :** a détecté 4 issues corrigées :
|
||||
- `component.entityId` → `component.composantId` (property n'existait pas)
|
||||
- `piece.entityId` → `piece.pieceId`
|
||||
- `table-fixed` global → opt-in via prop `fixedLayout`
|
||||
- NuxtLinks sans `?from=machine&machineId=xxx` → ajouté
|
||||
|
||||
### 4. Phase 2 — Refactoring structurel (7 améliorations)
|
||||
|
||||
**Plan :** `docs/superpowers/plans/2026-04-04-ux-phase2-structural.md`
|
||||
|
||||
| Changement | Fichiers créés/modifiés |
|
||||
|-----------|------------------------|
|
||||
| EntityTabs composant partagé | `components/common/EntityTabs.vue` (nouveau) |
|
||||
| Onglets page machine + header compact | machine/[id].vue, MachineDetailHeader.vue |
|
||||
| Onglets composant/pièce/produit | 3 pages détail |
|
||||
| Pages catalogue unifiées /catalogues/* | 3 nouvelles pages + ManagementView modifié |
|
||||
| Navbar réorganisée (Catalogues + Administration) | AppNavbar.vue |
|
||||
| Breadcrumbs contextuels | `components/layout/AppBreadcrumb.vue` (nouveau), app.vue |
|
||||
| Redirections legacy URLs | `middleware/legacy-redirects.global.ts` (nouveau) |
|
||||
| Guard modifications non sauvegardées | `composables/useUnsavedGuard.ts` (nouveau) |
|
||||
|
||||
### 5. Phase 3 — Harmonisation visuelle (3 améliorations)
|
||||
|
||||
| Changement | Fichiers |
|
||||
|-----------|----------|
|
||||
| EmptyState composant partagé | `components/common/EmptyState.vue` (nouveau), 3 pages |
|
||||
| Toasts erreur persistent + barre progression | useToast.ts, ToastContainer.vue |
|
||||
| Responsive mobile (breadcrumbs tronqués, tabs scroll) | AppBreadcrumb, EntityTabs, vérification grids |
|
||||
|
||||
### 6. Phase 4 — Backend + reverse links (6 améliorations)
|
||||
|
||||
| Changement | Fichiers |
|
||||
|-----------|----------|
|
||||
| Endpoint `/api/{entity}/{id}/used-in` | `src/Controller/UsedInController.php` (nouveau) |
|
||||
| UsedInSection frontend | `composables/useUsedIn.ts` + `components/common/UsedInSection.vue` (nouveaux), 3 pages détail |
|
||||
| Endpoint `/api/constructeurs/stats` | `src/Controller/ConstructeurStatsController.php` (nouveau) |
|
||||
| Page fournisseurs enrichie (compteurs cliquables) | constructeurs.vue |
|
||||
| Endpoint `/api/model_types/{id}/related-items` | `src/Controller/ModelTypeRelatedItemsController.php` (nouveau) |
|
||||
| Modal catégorie enrichie (machine count + liens) | RelatedItemsModal.vue |
|
||||
|
||||
## Bugs découverts et corrigés en cours de route
|
||||
|
||||
| Bug | Cause | Fix |
|
||||
|-----|-------|-----|
|
||||
| `<script setup>` sans `lang="ts"` | Agents ont ajouté `as string` dans des fichiers JS | Ajouté `lang="ts"` sur ComponentItem, PieceItem, machine/[id] |
|
||||
| `Cannot access 'selectedType' before initialization` | Bug pré-existant dans usePieceEdit.ts — `resolvedStructure` utilisait `selectedType` avant sa déclaration | Déplacé `resolvedStructure` avant `useCustomFieldInputs` |
|
||||
| `CommonEmptyState` non résolu | `pathPrefix: false` dans nuxt.config → les composants dans `common/` s'importent sans préfixe | Renommé `CommonEmptyState` → `EmptyState`, `CommonUsedInSection` → `UsedInSection` |
|
||||
| `/api/constructeurs/stats` retourne 404 | Route API Platform `/api/constructeurs/{id}` matchait "stats" comme un {id} | Ajouté `priority: 1` sur la route bulk stats |
|
||||
| Compteurs fournisseurs tous à 0 | Tables `*_constructeur_links` vides — liens jamais migrés depuis les tables legacy M2M | Restauré depuis backup + créé migration Doctrine |
|
||||
| Pages `/catalogues/*` manquantes sur le disque | Fichiers committés par agents mais perdus dans le working tree (confusion `frontend/` vs `app/`) | Restauré depuis git history |
|
||||
|
||||
## Problème de données découvert
|
||||
|
||||
Les **liens constructeur ↔ entités** n'avaient jamais été migrés des anciennes tables ManyToMany (`_composantconstructeurs`, `_piececonstructeurs`) vers les nouvelles tables de liens (`*_constructeur_links`). Ce problème est **pré-existant** au refactoring UX.
|
||||
|
||||
### Données restaurées en local
|
||||
- 3 liens composant-constructeur
|
||||
- 23 liens pièce-constructeur (dont 6 Limatech remappé avec le nouvel ID)
|
||||
|
||||
### Données irrémédiablement perdues (entités supprimées)
|
||||
- **Convoyeur à Bande** → était lié à Brillaud + Bühler
|
||||
- **Sangle E12** → était liée à NETCO
|
||||
- **Arbre du tambour tête E6** → était lié à Dexis
|
||||
|
||||
### Migrations créées pour la prod
|
||||
1. `migrations/Version20260405_MigrateConstructeurLinks.php` — copie depuis les tables legacy M2M (si elles existent)
|
||||
2. `migrations/Version20260405_RestoreConstructeurLinksFromBackup.php` — fallback : insère directement les données du backup (3), nettoie les orphelins
|
||||
|
||||
**Pour restaurer en prod :** `php bin/console doctrine:migrations:migrate`
|
||||
|
||||
## Fichiers de référence
|
||||
|
||||
| Fichier | Contenu |
|
||||
|---------|---------|
|
||||
| `docs/superpowers/specs/2026-04-04-ux-overhaul-design.md` | Spec complète des 23 améliorations |
|
||||
| `docs/superpowers/plans/2026-04-04-ux-quick-wins.md` | Plan Phase 1 (11 tasks) |
|
||||
| `docs/superpowers/plans/2026-04-04-ux-phase2-structural.md` | Plan Phase 2 (11 tasks) |
|
||||
| `docs/superpowers/session-2026-04-04-ux-overhaul.md` | Ce résumé |
|
||||
|
||||
## Branche
|
||||
`feat/ux-quick-wins` — ~30 commits depuis `develop`
|
||||
|
||||
## Nouveaux composants/composables créés
|
||||
- `app/components/common/EntityTabs.vue`
|
||||
- `app/components/common/EmptyState.vue`
|
||||
- `app/components/common/UsedInSection.vue`
|
||||
- `app/components/layout/AppBreadcrumb.vue`
|
||||
- `app/composables/useUsedIn.ts`
|
||||
- `app/composables/useUnsavedGuard.ts`
|
||||
- `app/middleware/legacy-redirects.global.ts`
|
||||
- `app/pages/catalogues/composants.vue`
|
||||
- `app/pages/catalogues/pieces.vue`
|
||||
- `app/pages/catalogues/produits.vue`
|
||||
|
||||
## Nouveaux controllers backend
|
||||
- `src/Controller/UsedInController.php`
|
||||
- `src/Controller/ConstructeurStatsController.php`
|
||||
- `src/Controller/ModelTypeRelatedItemsController.php`
|
||||
|
||||
## Points d'attention pour la suite
|
||||
1. **Tester visuellement** toutes les pages sur `localhost:3001` avant merge
|
||||
2. **Lancer les migrations en prod** pour restaurer les liens constructeur
|
||||
3. Les anciennes URLs (`/component-catalog`, `/pieces-catalog`, etc.) redirigent automatiquement
|
||||
4. Le menu Administration n'est visible que pour les gestionnaires/admins (`canEdit`)
|
||||
5. L'onglet Catégories dans les pages catalogue n'est visible que pour `canEdit`
|
||||
6. Le `useUnsavedGuard` n'est pas encore intégré dans les pages (composable créé, pas branché)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
Retour au catalogue
|
||||
</button>
|
||||
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md">
|
||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
{{ backLabel }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -25,8 +26,9 @@
|
||||
<script setup lang="ts">
|
||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
@@ -34,18 +36,24 @@ const props = defineProps<{
|
||||
isEditMode: boolean
|
||||
canEdit: boolean
|
||||
backLink: string
|
||||
backLinkLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-edit': []
|
||||
}>()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
const backDestination = computed(() => {
|
||||
if (route.query.from === 'machine' && route.query.machineId) {
|
||||
return `/machine/${route.query.machineId}`
|
||||
}
|
||||
else {
|
||||
navigateTo(props.backLink)
|
||||
return props.backLink
|
||||
})
|
||||
|
||||
const backLabel = computed(() => {
|
||||
if (route.query.from === 'machine') {
|
||||
return 'Retour à la machine'
|
||||
}
|
||||
}
|
||||
return props.backLinkLabel ?? 'Retour au catalogue'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
@@ -13,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[]
|
||||
|
||||
43
frontend/app/components/common/CustomFieldNameInput.vue
Normal file
43
frontend/app/components/common/CustomFieldNameInput.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<SearchSelect
|
||||
:model-value="modelValue"
|
||||
:options="options"
|
||||
:placeholder="placeholder"
|
||||
option-value="name"
|
||||
option-label="name"
|
||||
creatable
|
||||
:size="size"
|
||||
@update:model-value="onUpdate"
|
||||
@focus="ensureLoaded"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import SearchSelect from './SearchSelect.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
}>(), {
|
||||
placeholder: 'Nom du champ',
|
||||
size: 'xs',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { suggestions, load } = useCustomFieldNameSuggestions()
|
||||
|
||||
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
|
||||
|
||||
function ensureLoaded(): void {
|
||||
void load()
|
||||
}
|
||||
|
||||
function onUpdate(value: string | number): void {
|
||||
emit('update:modelValue', String(value ?? ''))
|
||||
}
|
||||
</script>
|
||||
@@ -71,7 +71,7 @@
|
||||
>
|
||||
<span class="loading loading-spinner text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
<table :class="['table table-sm md:table-md', tableClass]">
|
||||
<table :class="['table table-sm md:table-md', tableClass, { 'table-fixed': fixedLayout }]">
|
||||
<thead>
|
||||
<!-- Header labels + sort -->
|
||||
<tr>
|
||||
@@ -85,6 +85,7 @@
|
||||
alignClass(col),
|
||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||
]"
|
||||
:style="col.minWidth ? { minWidth: col.minWidth } : undefined"
|
||||
>
|
||||
<slot :name="`header-${col.key}`" :column="col">
|
||||
<span
|
||||
@@ -221,6 +222,8 @@ const props = withDefaults(defineProps<{
|
||||
tableClass?: string
|
||||
showCounter?: boolean
|
||||
showPerPage?: boolean
|
||||
/** Use table-layout: fixed for stable column widths. Only enable on tables where columns define width/minWidth. */
|
||||
fixedLayout?: boolean
|
||||
}>(), {
|
||||
rowKey: 'id',
|
||||
loading: false,
|
||||
|
||||
33
frontend/app/components/common/EmptyState.vue
Normal file
33
frontend/app/components/common/EmptyState.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="text-center py-12">
|
||||
<div v-if="icon" class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
|
||||
<component :is="icon" class="w-8 h-8 text-base-content/30" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-base-content mb-1">{{ title }}</h3>
|
||||
<p v-if="description" class="text-sm text-base-content/50 mb-6">{{ description }}</p>
|
||||
<slot>
|
||||
<NuxtLink v-if="actionTo" :to="actionTo" class="btn btn-primary btn-sm">
|
||||
{{ actionLabel }}
|
||||
</NuxtLink>
|
||||
<button v-else-if="actionLabel" type="button" class="btn btn-primary btn-sm" @click="$emit('action')">
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
actionLabel?: string
|
||||
actionTo?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
action: []
|
||||
}>()
|
||||
</script>
|
||||
42
frontend/app/components/common/EntityTabs.vue
Normal file
42
frontend/app/components/common/EntityTabs.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="tabs tabs-bordered mb-6 overflow-x-auto flex-nowrap" role="tablist" :aria-label="ariaLabel">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': modelValue === tab.key }"
|
||||
role="tab"
|
||||
:aria-selected="modelValue === tab.key"
|
||||
@click="emit('update:modelValue', tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.count !== undefined && tab.count > 0" class="badge badge-outline badge-xs ml-1.5">
|
||||
{{ tab.count }}
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div role="tabpanel">
|
||||
<slot :name="`tab-${modelValue}`" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface TabDefinition {
|
||||
key: string
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
tabs: TabDefinition[]
|
||||
modelValue: string
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -77,6 +77,15 @@
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="creatableSuggestion"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
|
||||
@click="confirmCreatable"
|
||||
>
|
||||
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
|
||||
Créer « {{ creatableSuggestion }} »
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
@@ -87,6 +96,7 @@
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -137,10 +147,14 @@ const props = defineProps({
|
||||
serverSearch: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
creatable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search'])
|
||||
const emit = defineEmits(['update:modelValue', 'search', 'focus'])
|
||||
|
||||
const searchTerm = ref('')
|
||||
const openDropdown = ref(false)
|
||||
@@ -172,6 +186,18 @@ const displayedOptions = computed(() => {
|
||||
return filtered
|
||||
})
|
||||
|
||||
const creatableSuggestion = computed(() => {
|
||||
if (!props.creatable) return null
|
||||
const term = searchTerm.value.trim()
|
||||
if (!term) return null
|
||||
// Show "Créer ..." only if no option matches exactly (case-insensitive)
|
||||
const exists = baseOptions.value.some(option => {
|
||||
const label = resolveLabel(option).toLowerCase()
|
||||
return label === term.toLowerCase()
|
||||
})
|
||||
return exists ? null : term
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
||||
const base = ['input', 'input-bordered', 'w-full', pr]
|
||||
@@ -194,6 +220,12 @@ const toggleButtonClasses = computed(() => {
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (props.creatable) {
|
||||
if (searchTerm.value !== props.modelValue) {
|
||||
searchTerm.value = String(props.modelValue ?? '')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!openDropdown.value) {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
}
|
||||
@@ -269,6 +301,7 @@ function handleFocus () {
|
||||
if (searchTerm.value === '' && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
function toggleDropdown () {
|
||||
@@ -285,6 +318,9 @@ function handleInput () {
|
||||
if (!openDropdown.value) {
|
||||
openDropdown.value = true
|
||||
}
|
||||
if (props.creatable) {
|
||||
emit('update:modelValue', searchTerm.value)
|
||||
}
|
||||
emit('search', searchTerm.value)
|
||||
}
|
||||
|
||||
@@ -294,8 +330,18 @@ function clearSelection () {
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function confirmCreatable () {
|
||||
if (creatableSuggestion.value) {
|
||||
emit('update:modelValue', creatableSuggestion.value)
|
||||
}
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function closeDropdown () {
|
||||
openDropdown.value = false
|
||||
if (props.creatable) {
|
||||
return // keep the typed text as-is
|
||||
}
|
||||
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||
emit('update:modelValue', '')
|
||||
} else if (selectedOption.value) {
|
||||
@@ -342,7 +388,11 @@ const handleGlobalClick = (event) => {
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleGlobalClick)
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
if (props.creatable) {
|
||||
searchTerm.value = String(props.modelValue ?? '')
|
||||
} else {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
49
frontend/app/components/common/UsedInSection.vue
Normal file
49
frontend/app/components/common/UsedInSection.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div v-if="!loading && totalCount > 0" class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4">
|
||||
<h3 class="font-semibold text-base-content">Utilisé dans</h3>
|
||||
|
||||
<div v-if="data.machines.length" class="space-y-1">
|
||||
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Machines</p>
|
||||
<div v-for="m in data.machines" :key="m.id" class="flex items-center gap-2 text-sm">
|
||||
<NuxtLink :to="`/machine/${m.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
|
||||
{{ m.name }}
|
||||
</NuxtLink>
|
||||
<span v-if="m.site?.name" class="badge badge-ghost badge-xs">{{ m.site.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.composants.length" class="space-y-1">
|
||||
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Composants</p>
|
||||
<div v-for="c in data.composants" :key="c.id" class="text-sm">
|
||||
<NuxtLink :to="`/component/${c.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
|
||||
{{ c.name }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.pieces.length" class="space-y-1">
|
||||
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Pièces</p>
|
||||
<div v-for="p in data.pieces" :key="p.id" class="text-sm">
|
||||
<NuxtLink :to="`/piece/${p.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
|
||||
{{ p.name }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="flex justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
entityType: 'composants' | 'pieces' | 'products'
|
||||
entityId: string | null
|
||||
}>()
|
||||
|
||||
const { data, loading, totalCount } = useUsedIn(
|
||||
computed(() => props.entityType),
|
||||
computed(() => props.entityId),
|
||||
)
|
||||
</script>
|
||||
153
frontend/app/components/form/ConstructeurCategorieSelect.vue
Normal file
153
frontend/app/components/form/ConstructeurCategorieSelect.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="constructeur-categorie-select space-y-2">
|
||||
<div class="flex flex-wrap gap-2 min-h-[1.75rem]">
|
||||
<span v-if="!selected.length" class="text-sm text-base-content/50">
|
||||
Aucune catégorie
|
||||
</span>
|
||||
<span
|
||||
v-for="cat in selected"
|
||||
:key="cat.id || cat.name"
|
||||
class="badge badge-outline badge-lg gap-1"
|
||||
>
|
||||
<span>{{ cat.name }}</span>
|
||||
<button
|
||||
v-if="!disabled"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs p-0 h-auto min-h-0"
|
||||
aria-label="Retirer la catégorie"
|
||||
@click="removeCategory(cat)"
|
||||
>
|
||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!disabled" class="relative">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md w-full"
|
||||
:placeholder="placeholder"
|
||||
@focus="open = true; ensureLoaded()"
|
||||
@keydown.escape="open = false"
|
||||
>
|
||||
<div
|
||||
v-if="open && (matches.length || canCreate)"
|
||||
class="absolute z-30 mt-1 w-full max-h-56 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="cat in matches"
|
||||
:key="cat.id"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm"
|
||||
@click="addCategory(cat)"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canCreate"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm text-primary"
|
||||
@click="createAndAdd"
|
||||
>
|
||||
+ Créer « {{ searchTerm.trim() }} »
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<ConstructeurCategorie[]>,
|
||||
default: () => [],
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Rechercher ou créer une catégorie…',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: ConstructeurCategorie[]): void
|
||||
}>()
|
||||
|
||||
const { categories, loadCategories, createCategory } = useConstructeurCategories()
|
||||
|
||||
const searchTerm = ref('')
|
||||
const open = ref(false)
|
||||
const loadedOnce = ref(false)
|
||||
|
||||
const selected = computed<ConstructeurCategorie[]>(() => props.modelValue || [])
|
||||
|
||||
const selectedKeys = computed(() => new Set(selected.value.map(c => (c.name || '').toLowerCase())))
|
||||
|
||||
const matches = computed<ConstructeurCategorie[]>(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
return categories.value
|
||||
.filter(c => !selectedKeys.value.has((c.name || '').toLowerCase()))
|
||||
.filter(c => !term || (c.name || '').toLowerCase().includes(term))
|
||||
.slice(0, 50)
|
||||
})
|
||||
|
||||
const canCreate = computed(() => {
|
||||
const term = searchTerm.value.trim()
|
||||
if (!term) {
|
||||
return false
|
||||
}
|
||||
const lower = term.toLowerCase()
|
||||
return !categories.value.some(c => (c.name || '').toLowerCase() === lower)
|
||||
&& !selectedKeys.value.has(lower)
|
||||
})
|
||||
|
||||
const ensureLoaded = async () => {
|
||||
if (loadedOnce.value) {
|
||||
return
|
||||
}
|
||||
loadedOnce.value = true
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
const emitSelection = (value: ConstructeurCategorie[]) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const addCategory = (cat: ConstructeurCategorie) => {
|
||||
if (selectedKeys.value.has((cat.name || '').toLowerCase())) {
|
||||
return
|
||||
}
|
||||
emitSelection([...selected.value, cat])
|
||||
searchTerm.value = ''
|
||||
}
|
||||
|
||||
const removeCategory = (cat: ConstructeurCategorie) => {
|
||||
emitSelection(selected.value.filter(c => c !== cat && c.id !== cat.id))
|
||||
}
|
||||
|
||||
const createAndAdd = async () => {
|
||||
const created = await createCategory(searchTerm.value)
|
||||
if (created) {
|
||||
addCategory(created)
|
||||
}
|
||||
}
|
||||
|
||||
const onDocumentClick = (event: Event) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target && !target.closest('.constructeur-categorie-select')) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
|
||||
</script>
|
||||
137
frontend/app/components/layout/AppBreadcrumb.vue
Normal file
137
frontend/app/components/layout/AppBreadcrumb.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<nav v-if="crumbs.length > 1" class="container mx-auto px-6 pt-4" aria-label="Fil d'Ariane">
|
||||
<div class="text-sm breadcrumbs py-0">
|
||||
<ul>
|
||||
<!-- First crumb (always visible) -->
|
||||
<li>
|
||||
<NuxtLink :to="crumbs[0].path" class="text-base-content/60 hover:text-primary transition-colors">
|
||||
{{ crumbs[0].label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<!-- Ellipsis on mobile when there are middle crumbs -->
|
||||
<li v-if="crumbs.length > 2" class="sm:hidden">
|
||||
<span class="text-base-content/40">…</span>
|
||||
</li>
|
||||
<!-- Middle crumbs: hidden on mobile, visible sm+ -->
|
||||
<li
|
||||
v-for="(crumb, i) in crumbs.slice(1, crumbs.length - 1)"
|
||||
:key="i"
|
||||
class="hidden sm:list-item"
|
||||
>
|
||||
<NuxtLink :to="crumb.path" class="text-base-content/60 hover:text-primary transition-colors">
|
||||
{{ crumb.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<!-- Last crumb (always visible, current page) -->
|
||||
<li v-if="crumbs.length > 1">
|
||||
<span class="text-base-content font-medium">{{ crumbs[crumbs.length - 1].label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Crumb {
|
||||
label: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const crumbs = computed<Crumb[]>(() => {
|
||||
const result: Crumb[] = [{ label: 'Accueil', path: '/' }]
|
||||
const path = route.path
|
||||
|
||||
// Home page — no breadcrumb
|
||||
if (path === '/') return []
|
||||
|
||||
// Machine context from query param (when navigating from a machine detail page)
|
||||
if (route.query.from === 'machine' && route.query.machineId) {
|
||||
result.push({ label: 'Parc machines', path: '/machines' })
|
||||
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` })
|
||||
}
|
||||
|
||||
// Machines
|
||||
if (path === '/machines') {
|
||||
result.push({ label: 'Parc machines', path: '/machines' })
|
||||
} else if (path.startsWith('/machine/') && !route.query.from) {
|
||||
result.push({ label: 'Parc machines', path: '/machines' })
|
||||
result.push({ label: 'Machine', path })
|
||||
}
|
||||
|
||||
// Catalogs
|
||||
else if (path.startsWith('/catalogues/composants')) {
|
||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||
} else if (path.startsWith('/catalogues/pieces')) {
|
||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||
} else if (path.startsWith('/catalogues/produits')) {
|
||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||
}
|
||||
|
||||
// Entity detail pages (when NOT from machine context)
|
||||
else if (path.startsWith('/component/') && !route.query.from) {
|
||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||
result.push({ label: 'Composant', path })
|
||||
} else if (path.startsWith('/piece/') && !route.query.from) {
|
||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||
result.push({ label: 'Pièce', path })
|
||||
} else if (path.startsWith('/product/') && !route.query.from) {
|
||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||
result.push({ label: 'Produit', path })
|
||||
}
|
||||
|
||||
// Entity detail pages WITH machine context — add entity as last crumb
|
||||
else if (path.startsWith('/component/') && route.query.from === 'machine') {
|
||||
result.push({ label: 'Composant', path })
|
||||
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
|
||||
result.push({ label: 'Pièce', path })
|
||||
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
|
||||
result.push({ label: 'Produit', path })
|
||||
}
|
||||
|
||||
// Admin pages
|
||||
else if (path.startsWith('/sites')) {
|
||||
result.push({ label: 'Sites', path: '/sites' })
|
||||
} else if (path.startsWith('/constructeurs')) {
|
||||
result.push({ label: 'Fournisseurs', path: '/constructeurs' })
|
||||
} else if (path.startsWith('/activity-log')) {
|
||||
result.push({ label: 'Journal d\'activité', path: '/activity-log' })
|
||||
} else if (path.startsWith('/admin')) {
|
||||
result.push({ label: 'Administration', path: '/admin' })
|
||||
} else if (path.startsWith('/documents')) {
|
||||
result.push({ label: 'Documents', path: '/documents' })
|
||||
} else if (path.startsWith('/comments')) {
|
||||
result.push({ label: 'Commentaires', path: '/comments' })
|
||||
}
|
||||
|
||||
// Category pages
|
||||
else if (path.startsWith('/component-category')) {
|
||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||
result.push({ label: 'Catégorie', path })
|
||||
} else if (path.startsWith('/piece-category')) {
|
||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||
result.push({ label: 'Catégorie', path })
|
||||
} else if (path.startsWith('/product-category')) {
|
||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||
result.push({ label: 'Catégorie', path })
|
||||
}
|
||||
|
||||
// Create pages
|
||||
else if (path.startsWith('/pieces/create')) {
|
||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
||||
result.push({ label: 'Nouvelle pièce', path })
|
||||
} else if (path.startsWith('/component/create')) {
|
||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
||||
result.push({ label: 'Nouveau composant', path })
|
||||
} else if (path.startsWith('/product/create')) {
|
||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
||||
result.push({ label: 'Nouveau produit', path })
|
||||
} else if (path === '/machines/new') {
|
||||
result.push({ label: 'Parc machines', path: '/machines' })
|
||||
result.push({ label: 'Nouvelle machine', path })
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<!-- Mobile: dropdown groups -->
|
||||
<li
|
||||
v-for="group in navGroups"
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.id + '-mobile'"
|
||||
class="mt-1 border-t border-base-200 pt-2"
|
||||
>
|
||||
@@ -122,7 +122,7 @@
|
||||
|
||||
<!-- Desktop: dropdown groups -->
|
||||
<li
|
||||
v-for="group in navGroups"
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.id + '-desktop'"
|
||||
class="relative"
|
||||
@mouseenter="setDropdown(group.id + '-desktop')"
|
||||
@@ -270,11 +270,9 @@ import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||
import IconLucideFactory from '~icons/lucide/factory'
|
||||
import IconLucideBookOpen from '~icons/lucide/book-open'
|
||||
|
||||
import IconLucideCpu from '~icons/lucide/cpu'
|
||||
import IconLucidePuzzle from '~icons/lucide/puzzle'
|
||||
import IconLucidePackage from '~icons/lucide/package'
|
||||
import IconLucideLink from '~icons/lucide/link'
|
||||
import IconLucideSun from '~icons/lucide/sun'
|
||||
import IconLucideMoon from '~icons/lucide/moon'
|
||||
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||
@@ -296,55 +294,40 @@ interface NavGroup {
|
||||
icon?: Component
|
||||
activePaths: string[]
|
||||
children: NavLink[]
|
||||
requiresEdit?: boolean
|
||||
}
|
||||
|
||||
const simpleLinks: NavLink[] = [
|
||||
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
||||
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
||||
{ to: '/doc', label: 'Documentation', icon: IconLucideBookOpen },
|
||||
]
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'component',
|
||||
label: 'Composants',
|
||||
icon: IconLucideCpu,
|
||||
activePaths: ['/component-category', '/component-catalog'],
|
||||
children: [
|
||||
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
||||
{ to: '/component-category', label: 'Catégorie de composant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pieces',
|
||||
label: 'Pièces',
|
||||
icon: IconLucidePuzzle,
|
||||
activePaths: ['/piece-category', '/pieces-catalog'],
|
||||
children: [
|
||||
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
||||
{ to: '/piece-category', label: 'Catégorie de pièce' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
label: 'Produits',
|
||||
id: 'catalogues',
|
||||
label: 'Catalogues',
|
||||
icon: IconLucidePackage,
|
||||
activePaths: ['/product-category', '/product-catalog'],
|
||||
activePaths: ['/catalogues', '/component', '/piece', '/product'],
|
||||
children: [
|
||||
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
||||
{ to: '/product-category', label: 'Catégorie de produit' },
|
||||
{ to: '/catalogues/composants', label: 'Composants' },
|
||||
{ to: '/catalogues/pieces', label: 'Pièces' },
|
||||
{ to: '/catalogues/produits', label: 'Produits' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
label: 'Ressources liées',
|
||||
icon: IconLucideLink,
|
||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||
id: 'admin',
|
||||
label: 'Administration',
|
||||
icon: IconLucideSettings,
|
||||
activePaths: ['/sites', '/constructeurs', '/activity-log', '/admin', '/documents', '/comments', '/component-category', '/piece-category', '/product-category'],
|
||||
requiresEdit: true,
|
||||
children: [
|
||||
{ to: '/sites', label: 'Sites' },
|
||||
{ to: '/documents', label: 'Documents' },
|
||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||
{ to: '/comments', label: 'Commentaires' },
|
||||
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||
{ to: '/admin', label: 'Profils' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -353,6 +336,10 @@ const route = useRoute()
|
||||
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
||||
const { activeProfile } = useProfileSession()
|
||||
const { isAdmin, canEdit } = usePermissions()
|
||||
|
||||
const visibleGroups = computed(() =>
|
||||
navGroups.filter(g => !g.requiresEdit || canEdit.value)
|
||||
)
|
||||
const { fetchUnresolvedCount } = useComments()
|
||||
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
|
||||
|
||||
|
||||
@@ -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>
|
||||
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
|
||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
Parc machines
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -43,11 +49,16 @@
|
||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucidePrinter from '~icons/lucide/printer'
|
||||
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
||||
|
||||
const router = useRouter()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
siteName?: string
|
||||
siteColor?: string
|
||||
reference?: string
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
@@ -56,12 +67,12 @@ defineEmits<{
|
||||
'open-print': []
|
||||
}>()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
const siteStyle = computed(() => {
|
||||
if (!props.siteColor) return {}
|
||||
return {
|
||||
borderColor: props.siteColor + '60',
|
||||
backgroundColor: props.siteColor + '25',
|
||||
color: props.siteColor,
|
||||
}
|
||||
else {
|
||||
navigateTo('/machines')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="card-title">Documents de la machine</h2>
|
||||
<h2 class="card-title">
|
||||
Documents de la machine
|
||||
<span v-if="documents.length" class="badge badge-outline badge-sm ml-1">{{ documents.length }}</span>
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500">Ajoutez ou consultez les documents liés à cette machine.</p>
|
||||
</div>
|
||||
<span v-if="isEditMode && files.length" class="badge badge-outline">
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
class="input input-bordered"
|
||||
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||
{{ machineName }}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -38,9 +38,9 @@
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||
{{ machineSiteName || 'Non défini' }}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="isEditMode || machineReference" class="form-control">
|
||||
<label class="label">
|
||||
@@ -54,9 +54,9 @@
|
||||
class="input input-bordered"
|
||||
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
<p v-else class="text-sm font-medium text-base-content py-1">
|
||||
{{ machineReference }}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
@@ -77,9 +77,9 @@
|
||||
@update:model-value="$emit('update:constructeur-links', $event)"
|
||||
@remove="$emit('remove-constructeur-link', $event)"
|
||||
/>
|
||||
<div v-else-if="!isEditMode" class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
|
||||
<span class="text-base-content/50">Non défini</span>
|
||||
</div>
|
||||
<p v-else-if="!isEditMode" class="text-sm font-medium text-base-content/50 py-1">
|
||||
Non défini
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,9 +152,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatCustomFieldValue(field) }}
|
||||
</div>
|
||||
<p class="text-sm font-medium text-base-content py-1">
|
||||
{{ formatValueForDisplay(field) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@ import { watch } from 'vue'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
|
||||
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
|
||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||
import { formatValueForDisplay } from '~/shared/utils/customFields'
|
||||
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">Pièces de la machine</h2>
|
||||
<h2 class="card-title">
|
||||
Pièces de la machine
|
||||
<span v-if="pieces.length" class="badge badge-outline badge-sm ml-1">{{ pieces.length }}</span>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
@@ -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' },
|
||||
@@ -335,13 +316,6 @@ const resolveCategoryBasePath = (category: ModelCategory) => {
|
||||
return '/product-category'
|
||||
}
|
||||
|
||||
const openCreatePage = () => {
|
||||
const basePath = resolveCategoryBasePath(selectedCategory.value)
|
||||
router.push(`${basePath}/new`).catch(() => {
|
||||
showError('Navigation impossible vers la page de création.')
|
||||
})
|
||||
}
|
||||
|
||||
const openEditPage = (item: ModelType) => {
|
||||
const category = item.category ?? selectedCategory.value
|
||||
const basePath = resolveCategoryBasePath(category)
|
||||
@@ -396,26 +370,6 @@ const openRelatedEdit = (entry: { id: string }) => {
|
||||
})
|
||||
}
|
||||
|
||||
const conversionModalOpen = ref(false)
|
||||
const conversionTarget = ref<ModelType | null>(null)
|
||||
|
||||
const openConversionModal = (item: ModelType) => {
|
||||
conversionTarget.value = item
|
||||
conversionModalOpen.value = true
|
||||
}
|
||||
|
||||
const closeConversionModal = () => {
|
||||
conversionModalOpen.value = false
|
||||
}
|
||||
|
||||
const onConverted = () => {
|
||||
conversionModalOpen.value = false
|
||||
invalidateEntityTypeCache('PIECE')
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
showSuccess('Catégorie convertie avec succès.')
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => searchInput.value,
|
||||
(value) => {
|
||||
|
||||
@@ -99,11 +99,7 @@
|
||||
v-else
|
||||
class="space-y-3 rounded-lg border border-base-300 p-4"
|
||||
>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="productStructure" />
|
||||
<PieceModelStructureEditor v-model="productStructure" hide-products />
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
@@ -194,20 +190,21 @@ const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
|
||||
})
|
||||
|
||||
const formulaBuilderCustomFields = computed(() => {
|
||||
let fields: any[] = []
|
||||
if (form.category === 'PIECE') {
|
||||
const fields = pieceStructure.value?.customFields
|
||||
return Array.isArray(fields) ? fields : []
|
||||
const raw = pieceStructure.value?.customFields
|
||||
fields = Array.isArray(raw) ? raw : []
|
||||
}
|
||||
if (form.category === 'COMPONENT') {
|
||||
const fields = componentStructure.value?.customFields
|
||||
return Array.isArray(fields) ? fields : []
|
||||
else if (form.category === 'COMPONENT') {
|
||||
const raw = componentStructure.value?.customFields
|
||||
fields = Array.isArray(raw) ? raw : []
|
||||
}
|
||||
return []
|
||||
return fields.filter((f: any) => !f.machineContextOnly)
|
||||
})
|
||||
|
||||
const extractFormulaFields = (formula: string | null | undefined): string[] => {
|
||||
if (!formula) return []
|
||||
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
|
||||
const matches = [...formula.matchAll(/\{([^}]+)\}/gu)]
|
||||
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ const preview = computed(() => {
|
||||
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
|
||||
}
|
||||
}
|
||||
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
|
||||
return props.modelValue.replace(/\{([^}]+)\}/gu, (_, name) => fieldMap.get(name) ?? '???')
|
||||
})
|
||||
|
||||
const insertField = (fieldName: string) => {
|
||||
|
||||
@@ -31,16 +31,28 @@
|
||||
:key="entry.id"
|
||||
class="px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
@click="onOpenEdit(entry)"
|
||||
<div
|
||||
class="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-2 hover:bg-base-200"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||
Référence: {{ entry.reference }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<NuxtLink
|
||||
:to="itemDetailPath(entry)"
|
||||
class="font-medium hover:underline hover:text-primary transition-colors"
|
||||
@click="emit('close')"
|
||||
>
|
||||
{{ entry.name }}
|
||||
</NuxtLink>
|
||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||
Référence: {{ entry.reference }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<span v-if="entry.machineCount > 0" class="badge badge-ghost badge-sm">
|
||||
{{ entry.machineCount }} machine{{ entry.machineCount > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-base-content/30">Aucune machine</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -57,14 +69,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import type { ModelCategory, ModelType } from '~/services/modelTypes'
|
||||
|
||||
type RelatedEntry = {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
machineCount: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -104,73 +115,37 @@ const modalSubtitle = computed(() => {
|
||||
return `${count} ${labels.plural} liés.`
|
||||
})
|
||||
|
||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
|
||||
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
|
||||
return { endpoint: '/products', filterKey: 'typeProduct' }
|
||||
}
|
||||
|
||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const record = item as Record<string, unknown>
|
||||
if (typeof record.id !== 'string') return null
|
||||
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
|
||||
const reference
|
||||
= typeof record.reference === 'string' && record.reference.trim()
|
||||
? record.reference
|
||||
: typeof record.code === 'string' && record.code.trim()
|
||||
? record.code
|
||||
: null
|
||||
return { id: record.id, name, reference }
|
||||
const itemDetailPath = (item: RelatedEntry) => {
|
||||
if (!props.modelType) return '#'
|
||||
const category = props.modelType.category
|
||||
if (category === 'COMPONENT') return `/component/${item.id}`
|
||||
if (category === 'PIECE') return `/piece/${item.id}`
|
||||
return `/product/${item.id}`
|
||||
}
|
||||
|
||||
const loadRelatedItems = async (modelType: ModelType) => {
|
||||
const { endpoint, filterKey } = resolveRelatedConfig(modelType.category)
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '200')
|
||||
params.set(filterKey, `/api/model_types/${modelType.id}`)
|
||||
params.set('order[name]', 'asc')
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
items.value = []
|
||||
|
||||
try {
|
||||
const result = await get(`${endpoint}?${params.toString()}`)
|
||||
const result = await get(`/model_types/${modelType.id}/related-items`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger les éléments liés.'
|
||||
return
|
||||
}
|
||||
const collection = extractCollection(result.data)
|
||||
items.value = collection
|
||||
.map(mapRelatedEntry)
|
||||
.filter((entry): entry is RelatedEntry => Boolean(entry))
|
||||
}
|
||||
catch (err) {
|
||||
let raw: string | null = null
|
||||
if (err && typeof err === 'object') {
|
||||
const e = err as { data?: Record<string, unknown>, statusMessage?: string, message?: string }
|
||||
if (e.data) {
|
||||
const data = e.data
|
||||
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
|
||||
else if (typeof data.detail === 'string') raw = data.detail
|
||||
else if (typeof data.message === 'string') raw = data.message
|
||||
else if (typeof data.error === 'string') raw = data.error
|
||||
}
|
||||
if (!raw && typeof e.statusMessage === 'string') raw = e.statusMessage
|
||||
if (!raw && typeof e.message === 'string') raw = e.message
|
||||
if (Array.isArray(result.data)) {
|
||||
items.value = result.data as RelatedEntry[]
|
||||
}
|
||||
error.value = humanizeError(raw)
|
||||
}
|
||||
catch {
|
||||
error.value = 'Impossible de charger les éléments liés.'
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenEdit = (entry: RelatedEntry) => {
|
||||
emit('open-edit', entry)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
<h3 class="card-title text-lg text-base-content">
|
||||
{{ site.name }}
|
||||
</h3>
|
||||
<div
|
||||
class="badge font-bold"
|
||||
<NuxtLink
|
||||
:to="`/machines?sites=${site.id}`"
|
||||
class="badge font-bold hover:opacity-80 transition-opacity"
|
||||
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
|
||||
:class="!site.color ? 'badge-primary' : ''"
|
||||
>
|
||||
{{ machineCount }} machines
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
@@ -39,10 +40,10 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-base-content/60">
|
||||
<NuxtLink :to="`/machines?sites=${site.id}`" class="flex items-center gap-2 text-base-content/60 hover:text-primary transition-colors">
|
||||
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
||||
<span>{{ machineCount }} machine(s)</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-4">
|
||||
|
||||
@@ -17,15 +17,9 @@ import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
normalizeCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
@@ -40,7 +34,6 @@ import {
|
||||
import {
|
||||
hasAssignments,
|
||||
initializeStructureAssignments,
|
||||
isAssignmentNodeComplete,
|
||||
serializeStructureAssignments,
|
||||
} from '~/shared/utils/structureAssignmentHelpers'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
@@ -77,7 +70,6 @@ export function useComponentCreate() {
|
||||
loading: productsLoading,
|
||||
} = useProducts()
|
||||
const toast = useToast()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { uploadDocuments } = useDocuments()
|
||||
const { syncLinks } = useConstructeurLinks()
|
||||
const { canEdit } = usePermissions()
|
||||
@@ -98,7 +90,8 @@ export function useComponentCreate() {
|
||||
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
const lastSuggestedName = ref('')
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const createdComponentId = ref<string | null>(null)
|
||||
|
||||
const structureAssignments = ref<StructureAssignmentNode | null>(null)
|
||||
const selectedDocuments = ref<File[]>([])
|
||||
const uploadingDocuments = ref(false)
|
||||
@@ -148,26 +141,24 @@ export function useComponentCreate() {
|
||||
return structure ? normalizeStructureForEditor(structure) : null
|
||||
})
|
||||
|
||||
const {
|
||||
fields: customFieldInputs,
|
||||
requiredFilled: requiredCustomFieldsFilled,
|
||||
saveAll: saveAllCustomFields,
|
||||
refresh: refreshCustomFieldInputs,
|
||||
} = useCustomFieldInputs({
|
||||
definitions: computed(() => selectedTypeStructure.value?.customFields ?? []),
|
||||
values: computed(() => []),
|
||||
entityType: 'composant',
|
||||
entityId: createdComponentId,
|
||||
context: 'standalone',
|
||||
})
|
||||
|
||||
const structureHasRequirements = computed(() =>
|
||||
hasAssignments(structureAssignments.value),
|
||||
)
|
||||
|
||||
const structureSelectionsComplete = computed(() => {
|
||||
if (!structureHasRequirements.value) {
|
||||
return true
|
||||
}
|
||||
if (structureDataLoading.value) {
|
||||
return false
|
||||
}
|
||||
if (!structureAssignments.value) {
|
||||
return false
|
||||
}
|
||||
return isAssignmentNodeComplete(structureAssignments.value, true)
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
const structureSelectionsComplete = computed(() => true)
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value
|
||||
@@ -225,7 +216,6 @@ export function useComponentCreate() {
|
||||
watch(selectedType, (type) => {
|
||||
if (!type) {
|
||||
clearCreationForm()
|
||||
customFieldInputs.value = []
|
||||
structureAssignments.value = null
|
||||
return
|
||||
}
|
||||
@@ -233,7 +223,8 @@ export function useComponentCreate() {
|
||||
creationForm.name = type.name
|
||||
}
|
||||
lastSuggestedName.value = creationForm.name
|
||||
customFieldInputs.value = normalizeCustomFieldInputs(selectedTypeStructure.value)
|
||||
// useCustomFieldInputs auto-refreshes via its watcher on definitions
|
||||
refreshCustomFieldInputs()
|
||||
structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value)
|
||||
})
|
||||
|
||||
@@ -305,11 +296,6 @@ export function useComponentCreate() {
|
||||
payload.productId = rootProductSelection.selectedProductId.trim()
|
||||
}
|
||||
|
||||
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
|
||||
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
|
||||
return
|
||||
}
|
||||
|
||||
const serializedStructure = structureHasRequirements.value
|
||||
? serializeStructureAssignments(structureAssignments.value)
|
||||
: null
|
||||
@@ -323,12 +309,11 @@ export function useComponentCreate() {
|
||||
const result = await createComposant(payload)
|
||||
if (result.success) {
|
||||
const createdComponent = result.data as Record<string, any>
|
||||
await _saveCustomFieldValues(
|
||||
'composant',
|
||||
createdComponent.id,
|
||||
[createdComponent?.typeComposant?.structure?.customFields],
|
||||
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
|
||||
)
|
||||
createdComponentId.value = createdComponent.id
|
||||
const failedFields = await saveAllCustomFields()
|
||||
if (failedFields.length) {
|
||||
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
|
||||
}
|
||||
if (selectedDocuments.value.length && result.data?.id) {
|
||||
uploadingDocuments.value = true
|
||||
const uploadResult = await uploadDocuments(
|
||||
@@ -413,6 +398,7 @@ export function useComponentCreate() {
|
||||
structureSelectionsComplete,
|
||||
canEdit,
|
||||
canSubmit,
|
||||
requiredCustomFieldsFilled,
|
||||
|
||||
// Functions
|
||||
typeOptionLabel,
|
||||
|
||||
@@ -6,14 +6,13 @@ import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||
import { useComponentHistory } from '~/composables/useComponentHistory'
|
||||
import { useEntityHistory } from '~/composables/useEntityHistory'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
@@ -29,12 +28,7 @@ import {
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
type CustomFieldInput,
|
||||
buildCustomFieldInputs,
|
||||
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
|
||||
saveCustomFieldValues as _saveCustomFieldValues,
|
||||
} from '~/shared/utils/customFieldFormUtils'
|
||||
import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
|
||||
import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils'
|
||||
|
||||
interface ComponentCatalogType extends ModelType {
|
||||
@@ -64,7 +58,6 @@ export function useComponentEdit(componentId: string) {
|
||||
const { products } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { fetchLinks, syncLinks } = useConstructeurLinks()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const {
|
||||
@@ -72,7 +65,7 @@ export function useComponentEdit(componentId: string) {
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useComponentHistory()
|
||||
} = useEntityHistory('composant')
|
||||
|
||||
const component = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
@@ -96,7 +89,6 @@ export function useComponentEdit(componentId: string) {
|
||||
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
|
||||
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() =>
|
||||
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
|
||||
@@ -207,18 +199,23 @@ export function useComponentEdit(componentId: string) {
|
||||
return structure ? normalizeStructureForEditor(structure) : null
|
||||
})
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: ComponentModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const structure = structureOverride ?? selectedTypeStructure.value ?? null
|
||||
const values = valuesOverride ?? component.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||
}
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
_requiredCustomFieldsFilled(customFieldInputs.value),
|
||||
)
|
||||
const {
|
||||
fields: customFieldInputs,
|
||||
requiredFilled: requiredCustomFieldsFilled,
|
||||
saveAll: saveAllCustomFields,
|
||||
refresh: refreshCustomFieldInputs,
|
||||
} = useCustomFieldInputs({
|
||||
definitions: computed(() => selectedTypeStructure.value?.customFields ?? []),
|
||||
values: computed(() => component.value?.customFieldValues ?? []),
|
||||
entityType: 'composant',
|
||||
entityId: computed(() => component.value?.id ?? null),
|
||||
context: 'standalone',
|
||||
onValueCreated: (newValue) => {
|
||||
if (component.value && Array.isArray(component.value.customFieldValues)) {
|
||||
component.value.customFieldValues.push(newValue)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => Boolean(
|
||||
canEdit.value
|
||||
@@ -239,8 +236,7 @@ export function useComponentEdit(componentId: string) {
|
||||
component.value = result.data
|
||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
// The watcher on useCustomFieldInputs will auto-refresh when component.value changes
|
||||
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
63
frontend/app/composables/useConstructeurCategories.ts
Normal file
63
frontend/app/composables/useConstructeurCategories.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface ConstructeurCategorie {
|
||||
'@id'?: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const categories = ref<ConstructeurCategorie[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const sortByName = (items: ConstructeurCategorie[]): ConstructeurCategorie[] =>
|
||||
[...items].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
|
||||
export function useConstructeurCategories() {
|
||||
const { get, post } = useApi()
|
||||
const { showError } = useToast()
|
||||
|
||||
const loadCategories = async (force = false): Promise<ConstructeurCategorie[]> => {
|
||||
if (loaded.value && !force) {
|
||||
return categories.value
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/constructeur_categories?itemsPerPage=1000')
|
||||
if (result.success) {
|
||||
categories.value = sortByName(extractCollection<ConstructeurCategorie>(result.data))
|
||||
loaded.value = true
|
||||
}
|
||||
return categories.value
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createCategory = async (name: string): Promise<ConstructeurCategorie | null> => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const existing = categories.value.find(c => c.name.toLowerCase() === trimmed.toLowerCase())
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const result = await post('/constructeur_categories', { name: trimmed })
|
||||
if (result.success && result.data && !Array.isArray(result.data)) {
|
||||
const created = result.data as ConstructeurCategorie
|
||||
categories.value = sortByName([...categories.value, created])
|
||||
return created
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return { categories, loading, loadCategories, createCategory }
|
||||
}
|
||||
@@ -1,13 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface ConstructeurTelephone {
|
||||
'@id'?: string
|
||||
id?: string
|
||||
numero: string
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
export interface ConstructeurCategorieRef {
|
||||
'@id'?: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Constructeur {
|
||||
'@id'?: string
|
||||
id: string
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
telephones?: ConstructeurTelephone[]
|
||||
categories?: ConstructeurCategorieRef[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface ConstructeurResult {
|
||||
@@ -16,6 +33,24 @@ interface ConstructeurResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ConstructeurPageOptions {
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
search?: string
|
||||
categoryId?: string
|
||||
orderField?: 'name' | 'email' | 'createdAt'
|
||||
orderDirection?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface ConstructeurPageResult {
|
||||
success: boolean
|
||||
items: Constructeur[]
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
currentPage: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
const constructeurs = ref<Constructeur[]>([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
@@ -66,8 +101,10 @@ export function useConstructeurs() {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
const result = await get(`/constructeurs${query}`)
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '2000')
|
||||
if (search) params.set('search', search)
|
||||
const result = await get(`/constructeurs?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
constructeurs.value = uniqueConstructeurs(items)
|
||||
@@ -87,7 +124,38 @@ export function useConstructeurs() {
|
||||
return loadConstructeurs(search)
|
||||
}
|
||||
|
||||
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
const fetchConstructeursPage = async (opts: ConstructeurPageOptions = {}): Promise<ConstructeurPageResult> => {
|
||||
const page = Math.max(1, opts.page ?? 1)
|
||||
const itemsPerPage = Math.max(1, opts.itemsPerPage ?? 30)
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(page))
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
if (opts.search && opts.search.trim()) params.set('search', opts.search.trim())
|
||||
if (opts.categoryId) params.set('categories.id', opts.categoryId)
|
||||
if (opts.orderField) {
|
||||
params.set(`order[${opts.orderField}]`, opts.orderDirection ?? 'asc')
|
||||
}
|
||||
const result = await get(`/constructeurs?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: result.error }
|
||||
}
|
||||
const items = extractCollection<Constructeur>(result.data)
|
||||
const totalItems = extractTotal(result.data, items.length)
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
|
||||
upsertConstructeurs(items)
|
||||
return { success: true, items, totalItems, totalPages, currentPage: page }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors du chargement de la page fournisseurs:', error)
|
||||
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post('/constructeurs', data)
|
||||
@@ -161,7 +229,7 @@ export function useConstructeurs() {
|
||||
.filter((item): item is Constructeur => item !== null)
|
||||
}
|
||||
|
||||
const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => {
|
||||
const updateConstructeur = async (id: string, data: Record<string, unknown>): Promise<ConstructeurResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/constructeurs/${id}`, data)
|
||||
@@ -210,6 +278,7 @@ export function useConstructeurs() {
|
||||
loading,
|
||||
loadConstructeurs,
|
||||
searchConstructeurs,
|
||||
fetchConstructeursPage,
|
||||
createConstructeur,
|
||||
updateConstructeur,
|
||||
deleteConstructeur,
|
||||
|
||||
205
frontend/app/composables/useCustomFieldInputs.ts
Normal file
205
frontend/app/composables/useCustomFieldInputs.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Unified reactive custom field management composable.
|
||||
*
|
||||
* Replaces: useEntityCustomFields.ts, custom field parts of useMachineDetailCustomFields.ts,
|
||||
* and inline custom field logic in useComponentEdit/useComponentCreate/usePieceEdit.
|
||||
*
|
||||
* DESIGN NOTE: Uses an internal mutable `ref` (not a `computed`) so that
|
||||
* save operations can update `customFieldValueId` in place without being
|
||||
* overwritten on the next reactivity cycle. Call `refresh()` to re-merge
|
||||
* from the source definitions + values (e.g. after fetching fresh data).
|
||||
*/
|
||||
|
||||
import { ref, watch, computed, type MaybeRef, toValue } from 'vue'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import {
|
||||
mergeDefinitionsWithValues,
|
||||
filterByContext,
|
||||
formatValueForSave,
|
||||
shouldPersist,
|
||||
requiredFieldsFilled,
|
||||
type CustomFieldDefinition,
|
||||
type CustomFieldValue,
|
||||
type CustomFieldInput,
|
||||
} from '~/shared/utils/customFields'
|
||||
|
||||
export type { CustomFieldDefinition, CustomFieldValue, CustomFieldInput }
|
||||
|
||||
export type CustomFieldEntityType =
|
||||
| 'machine'
|
||||
| 'composant'
|
||||
| 'piece'
|
||||
| 'product'
|
||||
| 'machineComponentLink'
|
||||
| 'machinePieceLink'
|
||||
|
||||
export interface UseCustomFieldInputsOptions {
|
||||
/** Custom field definitions (from ModelType structure or machine.customFields) */
|
||||
definitions: MaybeRef<any[]>
|
||||
/** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */
|
||||
values: MaybeRef<any[]>
|
||||
/** Entity type for API upsert calls */
|
||||
entityType: CustomFieldEntityType
|
||||
/** Entity ID for API upsert calls */
|
||||
entityId: MaybeRef<string | null>
|
||||
/** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */
|
||||
context?: 'standalone' | 'machine'
|
||||
/** Optional callback to update the source values array after a save (keeps parent reactive state in sync) */
|
||||
onValueCreated?: (newValue: { id: string; value: string; customField: any }) => void
|
||||
}
|
||||
|
||||
export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
|
||||
const { entityType, context } = options
|
||||
const {
|
||||
updateCustomFieldValue: updateApi,
|
||||
upsertCustomFieldValue,
|
||||
} = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
// Internal mutable state — NOT a computed, so save can mutate in place
|
||||
const _allFields = ref<CustomFieldInput[]>([])
|
||||
|
||||
// Re-merge from source definitions + values
|
||||
const refresh = () => {
|
||||
const defs = toValue(options.definitions)
|
||||
const vals = toValue(options.values)
|
||||
_allFields.value = mergeDefinitionsWithValues(defs, vals)
|
||||
}
|
||||
|
||||
// Auto-refresh when reactive sources change
|
||||
watch(
|
||||
() => [toValue(options.definitions), toValue(options.values)],
|
||||
() => refresh(),
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
// Filtered by context (standalone vs machine)
|
||||
const fields = computed<CustomFieldInput[]>(() => {
|
||||
if (!context) return _allFields.value
|
||||
return filterByContext(_allFields.value, context)
|
||||
})
|
||||
|
||||
// Validation
|
||||
const requiredFilled = computed(() => requiredFieldsFilled(fields.value))
|
||||
|
||||
// Build metadata for upsert when no customFieldId is available (legacy fallback)
|
||||
const _buildMetadata = (field: CustomFieldInput) => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
customFieldRequired: field.required,
|
||||
customFieldOptions: field.options,
|
||||
})
|
||||
|
||||
// Update a single field value
|
||||
const update = async (field: CustomFieldInput): Promise<boolean> => {
|
||||
const id = toValue(options.entityId)
|
||||
if (!id) {
|
||||
showError(`Impossible de sauvegarder le champ "${field.name}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
const value = formatValueForSave(field)
|
||||
|
||||
// Update existing value
|
||||
if (field.customFieldValueId) {
|
||||
const result: any = await updateApi(field.customFieldValueId, { value })
|
||||
if (result.success) {
|
||||
showSuccess(`Champ "${field.name}" mis à jour`)
|
||||
return true
|
||||
}
|
||||
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Create new value via upsert — with metadata fallback when no ID
|
||||
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
field.customFieldId,
|
||||
entityType,
|
||||
id,
|
||||
value,
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
// Mutate in place (safe — _allFields is a ref, not computed)
|
||||
if (result.data?.id) {
|
||||
field.customFieldValueId = result.data.id
|
||||
}
|
||||
if (result.data?.customField?.id) {
|
||||
field.customFieldId = result.data.customField.id
|
||||
}
|
||||
// Notify parent to update its reactive source
|
||||
if (options.onValueCreated && result.data) {
|
||||
options.onValueCreated(result.data)
|
||||
}
|
||||
showSuccess(`Champ "${field.name}" enregistré`)
|
||||
return true
|
||||
}
|
||||
|
||||
showError(`Erreur lors de l'enregistrement du champ "${field.name}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Save all fields that have values
|
||||
const saveAll = async (): Promise<string[]> => {
|
||||
const id = toValue(options.entityId)
|
||||
if (!id) return ['(entity ID missing)']
|
||||
|
||||
const failed: string[] = []
|
||||
|
||||
for (const field of fields.value) {
|
||||
if (!shouldPersist(field)) continue
|
||||
|
||||
const value = formatValueForSave(field)
|
||||
|
||||
if (field.customFieldValueId) {
|
||||
const result: any = await updateApi(field.customFieldValueId, { value })
|
||||
if (!result.success) failed.push(field.name)
|
||||
continue
|
||||
}
|
||||
|
||||
// Upsert with metadata fallback when no customFieldId
|
||||
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
field.customFieldId,
|
||||
entityType,
|
||||
id,
|
||||
value,
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
if (result.data?.id) {
|
||||
field.customFieldValueId = result.data.id
|
||||
}
|
||||
if (result.data?.customField?.id) {
|
||||
field.customFieldId = result.data.customField.id
|
||||
}
|
||||
if (options.onValueCreated && result.data) {
|
||||
options.onValueCreated(result.data)
|
||||
}
|
||||
} else {
|
||||
failed.push(field.name)
|
||||
}
|
||||
}
|
||||
|
||||
return failed
|
||||
}
|
||||
|
||||
return {
|
||||
/** All merged fields filtered by context */
|
||||
fields,
|
||||
/** All merged fields (unfiltered) */
|
||||
allFields: _allFields,
|
||||
/** Whether all required fields have values */
|
||||
requiredFilled,
|
||||
/** Update a single field value via API */
|
||||
update,
|
||||
/** Save all fields with values, returns list of failed field names */
|
||||
saveAll,
|
||||
/** Re-merge from source definitions + values (call after fetching fresh data) */
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
41
frontend/app/composables/useCustomFieldNameSuggestions.ts
Normal file
41
frontend/app/composables/useCustomFieldNameSuggestions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const cache = ref<string[] | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
export function useCustomFieldNameSuggestions() {
|
||||
const api = useApi()
|
||||
|
||||
async function load(force = false): Promise<string[]> {
|
||||
if (cache.value && !force) return cache.value
|
||||
if (loading.value) return cache.value ?? []
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get<string[]>('/custom-fields/names')
|
||||
if (response.success && Array.isArray(response.data)) {
|
||||
cache.value = response.data
|
||||
}
|
||||
else {
|
||||
cache.value = cache.value ?? []
|
||||
if (response.error) {
|
||||
console.error('[useCustomFieldNameSuggestions] load failed:', response.error)
|
||||
}
|
||||
}
|
||||
return cache.value
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function invalidate(): void {
|
||||
cache.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: cache,
|
||||
loading,
|
||||
load,
|
||||
invalidate,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
@@ -58,13 +58,6 @@ const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') return p.totalItems
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useDocuments() {
|
||||
const { get, patch, postFormData, delete: del } = useApi()
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Reactive custom field management for entity items (ComponentItem, PieceItem).
|
||||
*
|
||||
* Wraps the pure logic from entityCustomFieldLogic.ts with Vue reactivity,
|
||||
* watchers, and API calls for updating/upserting custom field values.
|
||||
*/
|
||||
|
||||
import { computed, watch } from 'vue'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import {
|
||||
buildDefinitionSources,
|
||||
buildCandidateCustomFields,
|
||||
mergeFieldDefinitionsWithValues,
|
||||
dedupeMergedFields,
|
||||
ensureCustomFieldId,
|
||||
resolveFieldId,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldReadOnly,
|
||||
resolveCustomFieldId,
|
||||
buildCustomFieldMetadata,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
|
||||
export interface EntityCustomFieldsDeps {
|
||||
entity: () => any
|
||||
entityType: 'composant' | 'piece'
|
||||
}
|
||||
|
||||
export function useEntityCustomFields(deps: EntityCustomFieldsDeps) {
|
||||
const { entity, entityType } = deps
|
||||
const {
|
||||
updateCustomFieldValue: updateCustomFieldValueApi,
|
||||
upsertCustomFieldValue,
|
||||
} = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const definitionSources = computed(() =>
|
||||
buildDefinitionSources(entity(), entityType),
|
||||
)
|
||||
|
||||
const displayedCustomFields = computed(() =>
|
||||
dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(
|
||||
definitionSources.value,
|
||||
entity().customFieldValues,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const candidateCustomFields = computed(() =>
|
||||
buildCandidateCustomFields(entity(), definitionSources.value),
|
||||
)
|
||||
|
||||
// Watchers to ensure field IDs are resolved
|
||||
watch(
|
||||
candidateCustomFields,
|
||||
() => {
|
||||
const candidates = candidateCustomFields.value
|
||||
;(displayedCustomFields.value || []).forEach((field: any) => {
|
||||
if (field) ensureCustomFieldId(field, candidates)
|
||||
})
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
displayedCustomFields,
|
||||
(fields) => {
|
||||
const candidates = candidateCustomFields.value
|
||||
;(fields || []).forEach((field: any) => {
|
||||
if (field) ensureCustomFieldId(field, candidates)
|
||||
})
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const updateCustomField = async (field: any) => {
|
||||
if (!field || resolveFieldReadOnly(field)) return
|
||||
|
||||
const e = entity()
|
||||
const fieldValueId = resolveFieldId(field)
|
||||
|
||||
// Update existing field value
|
||||
if (fieldValueId) {
|
||||
const result: any = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
|
||||
if (result.success) {
|
||||
const existingValue = e.customFieldValues?.find((v: any) => v.id === fieldValueId)
|
||||
if (existingValue?.customField?.id) {
|
||||
field.customFieldId = existingValue.customField.id
|
||||
field.customField = existingValue.customField
|
||||
}
|
||||
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create new field value
|
||||
const customFieldId = ensureCustomFieldId(field, candidateCustomFields.value)
|
||||
const fieldName = resolveFieldName(field)
|
||||
if (!e?.id) {
|
||||
showError(`Impossible de créer la valeur pour ce champ`)
|
||||
return
|
||||
}
|
||||
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
|
||||
showError(`Impossible de créer la valeur pour ce champ`)
|
||||
return
|
||||
}
|
||||
|
||||
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
|
||||
const result: any = await upsertCustomFieldValue(
|
||||
customFieldId,
|
||||
entityType,
|
||||
e.id,
|
||||
field.value ?? '',
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
const newValue = result.data
|
||||
if (newValue?.id) {
|
||||
field.customFieldValueId = newValue.id
|
||||
field.value = newValue.value ?? field.value ?? ''
|
||||
if (newValue.customField?.id) {
|
||||
field.customFieldId = newValue.customField.id
|
||||
field.customField = newValue.customField
|
||||
}
|
||||
|
||||
if (Array.isArray(e.customFieldValues)) {
|
||||
const index = e.customFieldValues.findIndex((v: any) => v.id === newValue.id)
|
||||
if (index !== -1) {
|
||||
e.customFieldValues.splice(index, 1, newValue)
|
||||
} else {
|
||||
e.customFieldValues.push(newValue)
|
||||
}
|
||||
} else {
|
||||
e.customFieldValues = [newValue]
|
||||
}
|
||||
}
|
||||
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
|
||||
|
||||
// Update definitions list
|
||||
const definitions = Array.isArray(e.customFields) ? [...e.customFields] : []
|
||||
const fieldIdentifier = ensureCustomFieldId(field, candidateCustomFields.value)
|
||||
const existingIndex = definitions.findIndex((definition: any) => {
|
||||
const definitionId = resolveCustomFieldId(definition)
|
||||
if (fieldIdentifier && definitionId) return definitionId === fieldIdentifier
|
||||
return definition?.name === resolveFieldName(field)
|
||||
})
|
||||
|
||||
const updatedDefinition = {
|
||||
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
|
||||
customFieldValueId: field.customFieldValueId,
|
||||
customFieldId: fieldIdentifier,
|
||||
name: resolveFieldName(field),
|
||||
type: resolveFieldType(field),
|
||||
required: field.required ?? false,
|
||||
options: field.options ?? [],
|
||||
value: field.value ?? '',
|
||||
customField: field.customField ?? null,
|
||||
}
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
definitions.splice(existingIndex, 1, updatedDefinition)
|
||||
} else {
|
||||
definitions.push(updatedDefinition)
|
||||
}
|
||||
e.customFields = definitions
|
||||
} else {
|
||||
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayedCustomFields,
|
||||
candidateCustomFields,
|
||||
updateCustomField,
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import {
|
||||
listModelTypes,
|
||||
@@ -79,6 +80,7 @@ export function invalidateEntityTypeCache(category: ModelCategory) {
|
||||
export function useEntityTypes(config: EntityTypeConfig) {
|
||||
const { category, label } = config
|
||||
const { showSuccess, showError } = useToast()
|
||||
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
|
||||
const state = getOrCreateState(category)
|
||||
|
||||
const normalizeItem = (item: ModelType): EntityType => ({
|
||||
@@ -124,6 +126,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
})
|
||||
const normalized = normalizeItem(data)
|
||||
state.types.value.push(normalized)
|
||||
invalidateCustomFieldNames()
|
||||
showSuccess(`Type de ${label} "${data.name}" créé`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
@@ -150,6 +153,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
const normalized = normalizeItem(data)
|
||||
const index = state.types.value.findIndex((t) => t.id === id)
|
||||
if (index !== -1) state.types.value[index] = normalized
|
||||
invalidateCustomFieldNames()
|
||||
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
// --- Types ---
|
||||
@@ -88,6 +89,7 @@ const parseOptions = (optionsText: string): string[] =>
|
||||
export function useMachineCustomFieldDefs(deps: Deps) {
|
||||
const { apiCall } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
|
||||
|
||||
// --- State ---
|
||||
|
||||
@@ -294,6 +296,7 @@ export function useMachineCustomFieldDefs(deps: Deps) {
|
||||
}
|
||||
|
||||
showSuccess('Champs personnalisés sauvegardés avec succès')
|
||||
invalidateCustomFieldNames()
|
||||
await deps.onSaved()
|
||||
} catch {
|
||||
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import {
|
||||
shouldDisplayCustomField,
|
||||
normalizeExistingCustomFieldDefinitions,
|
||||
normalizeCustomFieldValueEntry,
|
||||
mergeCustomFieldValuesWithDefinitions,
|
||||
dedupeCustomFieldEntries,
|
||||
} from '~/shared/utils/customFieldUtils'
|
||||
mergeDefinitionsWithValues,
|
||||
filterByContext,
|
||||
hasDisplayableValue,
|
||||
type CustomFieldInput,
|
||||
} from '~/shared/utils/customFields'
|
||||
import {
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
@@ -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,
|
||||
|
||||
30
frontend/app/composables/useMaintenance.ts
Normal file
30
frontend/app/composables/useMaintenance.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
|
||||
const maintenanceEnabled = ref(false)
|
||||
|
||||
export function useMaintenance() {
|
||||
const { apiCall } = useApi()
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchStatus = async () => {
|
||||
const res = await apiCall<{ enabled: boolean }>('/admin/maintenance')
|
||||
if (res.success && res.data) {
|
||||
maintenanceEnabled.value = res.data.enabled
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiCall<{ enabled: boolean }>('/admin/maintenance', { method: 'PUT' })
|
||||
if (res.success && res.data) {
|
||||
maintenanceEnabled.value = res.data.enabled
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { maintenanceEnabled, loading, fetchStatus, toggle }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
32
frontend/app/composables/useUnsavedGuard.ts
Normal file
32
frontend/app/composables/useUnsavedGuard.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export function useUnsavedGuard(isDirty: Ref<boolean>) {
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (isDirty.value) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (!isDirty.value) return true
|
||||
const ok = await confirm({
|
||||
title: 'Modifications non sauvegardées',
|
||||
message: 'Vous avez des modifications en cours. Voulez-vous quitter sans sauvegarder ?',
|
||||
confirmText: 'Quitter sans sauver',
|
||||
cancelText: 'Rester',
|
||||
dangerous: true,
|
||||
})
|
||||
return ok
|
||||
})
|
||||
}
|
||||
52
frontend/app/composables/useUsedIn.ts
Normal file
52
frontend/app/composables/useUsedIn.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
interface UsedInMachine {
|
||||
id: string
|
||||
name: string
|
||||
site?: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
interface UsedInEntity {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface UsedInData {
|
||||
machines: UsedInMachine[]
|
||||
composants: UsedInEntity[]
|
||||
pieces: UsedInEntity[]
|
||||
}
|
||||
|
||||
export function useUsedIn(entityType: Ref<'composants' | 'pieces' | 'products'>, entityId: Ref<string | null>) {
|
||||
const data = ref<UsedInData>({ machines: [], composants: [], pieces: [] })
|
||||
const loading = ref(false)
|
||||
|
||||
const api = useApi()
|
||||
|
||||
const load = async () => {
|
||||
if (!entityId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await api.get(`/${entityType.value}/${entityId.value}/used-in`)
|
||||
if (result.success && result.data) {
|
||||
data.value = {
|
||||
machines: result.data.machines || [],
|
||||
composants: result.data.composants || [],
|
||||
pieces: result.data.pieces || [],
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = computed(() =>
|
||||
data.value.machines.length + data.value.composants.length + data.value.pieces.length
|
||||
)
|
||||
|
||||
watch(entityId, (val) => {
|
||||
if (val) load()
|
||||
}, { immediate: true })
|
||||
|
||||
return { data, loading, totalCount, load }
|
||||
}
|
||||
24
frontend/app/middleware/legacy-redirects.global.ts
Normal file
24
frontend/app/middleware/legacy-redirects.global.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const redirects: Record<string, string> = {
|
||||
'/component-catalog': '/catalogues/composants',
|
||||
'/pieces-catalog': '/catalogues/pieces',
|
||||
'/product-catalog': '/catalogues/produits',
|
||||
}
|
||||
|
||||
// Exact path match redirects
|
||||
const redirect = redirects[to.path]
|
||||
if (redirect) {
|
||||
return navigateTo({ path: redirect, query: to.query }, { redirectCode: 301 })
|
||||
}
|
||||
|
||||
// Category index redirects (add tab=categories query param)
|
||||
if (to.path === '/component-category') {
|
||||
return navigateTo({ path: '/catalogues/composants', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
|
||||
}
|
||||
if (to.path === '/piece-category') {
|
||||
return navigateTo({ path: '/catalogues/pieces', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
|
||||
}
|
||||
if (to.path === '/product-category') {
|
||||
return navigateTo({ path: '/catalogues/produits', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useProfileSession, usePermissions } from "#imports";
|
||||
import { useProfileSession, usePermissions, useApi } from "#imports";
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { ensureSession, activeProfile } = useProfileSession();
|
||||
@@ -12,9 +12,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
normalizedPath.startsWith("/profiles") ||
|
||||
fullPath.startsWith("/profiles") ||
|
||||
routeName.startsWith("profiles");
|
||||
const isMaintenanceRoute = normalizedPath === "/maintenance";
|
||||
|
||||
// Redirect to login if no active profile
|
||||
if (!activeProfile.value && !isProfilesRoute) {
|
||||
if (!activeProfile.value && !isProfilesRoute && !isMaintenanceRoute) {
|
||||
return navigateTo("/profiles");
|
||||
}
|
||||
|
||||
@@ -29,5 +30,13 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Maintenance mode check for non-admin users
|
||||
if (!isAdmin.value && !isMaintenanceRoute && !isProfilesRoute) {
|
||||
const { apiCall } = useApi();
|
||||
const res = await apiCall<{ enabled: boolean }>('/maintenance/check');
|
||||
if (res.success && res.data?.enabled) {
|
||||
return navigateTo("/maintenance");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-6 max-w-6xl">
|
||||
<!-- Maintenance Mode -->
|
||||
<div class="alert mb-6" :class="maintenanceEnabled ? 'alert-warning' : 'alert-info'">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Mode maintenance</span>
|
||||
<span v-if="maintenanceEnabled" class="badge badge-warning badge-sm">Actif</span>
|
||||
<span v-else class="badge badge-ghost badge-sm">Inactif</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="maintenanceEnabled ? 'btn-ghost' : 'btn-warning'"
|
||||
:disabled="maintenanceLoading"
|
||||
@click="handleToggleMaintenance"
|
||||
>
|
||||
<span v-if="maintenanceLoading" class="loading loading-spinner loading-xs" />
|
||||
{{ maintenanceEnabled ? 'Désactiver' : 'Activer' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm opacity-70 mt-1">
|
||||
{{ maintenanceEnabled ? 'Seuls les administrateurs peuvent accéder à l\'application.' : 'L\'application est accessible à tous les utilisateurs.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">
|
||||
Administration des profils
|
||||
@@ -153,9 +176,14 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useAdminProfiles } from '#imports'
|
||||
import { useAdminProfiles, useMaintenance } from '#imports'
|
||||
|
||||
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
||||
const { maintenanceEnabled, loading: maintenanceLoading, fetchStatus: fetchMaintenanceStatus, toggle: toggleMaintenance } = useMaintenance()
|
||||
|
||||
const handleToggleMaintenance = async () => {
|
||||
await toggleMaintenance()
|
||||
}
|
||||
|
||||
const loaded = ref(false)
|
||||
const isLoading = computed(() => loading.value || !loaded.value)
|
||||
@@ -264,7 +292,7 @@ const handleDeactivate = async (profileId) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAll()
|
||||
await Promise.all([fetchAll(), fetchMaintenanceStatus()])
|
||||
loaded.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
239
frontend/app/pages/catalogues/composants.vue
Normal file
239
frontend/app/pages/catalogues/composants.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Composants</h1>
|
||||
<p class="text-sm text-base-content/70">Catalogue et catégories de composants.</p>
|
||||
</div>
|
||||
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/component-category/new' : '/component/create'" class="btn btn-primary btn-sm md:btn-md">
|
||||
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter un composant' }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Composants">
|
||||
<template #tab-catalogue>
|
||||
<section class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
|
||||
<p class="text-sm text-base-content/50">
|
||||
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="componentRows"
|
||||
:loading="loadingComposants"
|
||||
:sort="table.sort.value"
|
||||
:pagination="paginationState"
|
||||
:column-filters="table.columnFilters.value"
|
||||
:show-per-page="true"
|
||||
empty-message="Aucun composant n'a encore été créé."
|
||||
no-results-message="Aucun composant ne correspond à votre recherche."
|
||||
@sort="table.handleSort"
|
||||
@update:current-page="table.handlePageChange"
|
||||
@update:per-page="table.handlePerPageChange"
|
||||
@update:column-filters="table.handleColumnFiltersChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="table.searchTerm.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
@input="table.debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template #cell-preview="{ row }">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.component)"
|
||||
:alt="resolvePreviewAlt(row.component)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
{{ row.component.name || 'Composant sans nom' }}
|
||||
</template>
|
||||
|
||||
<template #cell-reference="{ row }">
|
||||
{{ row.component.reference || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-referenceAuto="{ row }">
|
||||
{{ row.component.referenceAuto || '—' }}
|
||||
</template>
|
||||
|
||||
<template #cell-description="{ row }">
|
||||
<div v-if="row.component.description" class="group relative">
|
||||
<span class="block cursor-help truncate">{{ row.component.description }}</span>
|
||||
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
||||
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-typeComposant="{ row }">
|
||||
<NuxtLink
|
||||
v-if="row.component.typeComposant?.id"
|
||||
:to="`/component-category/${row.component.typeComposant.id}/edit`"
|
||||
class="link link-hover link-primary"
|
||||
>
|
||||
{{ resolveComponentType(row.component) }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ resolveComponentType(row.component) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-createdAt="{ row }">
|
||||
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(row.component)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<NuxtLink
|
||||
:to="`/component/${row.component.id}`"
|
||||
class="btn btn-primary btn-xs"
|
||||
>
|
||||
Détails
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template #tab-categories>
|
||||
<ManagementView
|
||||
category="COMPONENT"
|
||||
heading="Catégories de composant"
|
||||
:hide-heading="true"
|
||||
/>
|
||||
</template>
|
||||
</EntityTabs>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import ManagementView from '~/components/model-types/ManagementView.vue'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useDataTable } from '~/composables/useDataTable'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
||||
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
|
||||
const route = useRoute()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'catalogue')
|
||||
watch(activeTab, (val) => {
|
||||
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||
})
|
||||
|
||||
const pageTabs = computed(() => {
|
||||
const t: Array<{ key: string; label: string }> = [
|
||||
{ key: 'catalogue', label: 'Catalogue' },
|
||||
]
|
||||
if (canEdit.value) {
|
||||
t.push({ key: 'categories', label: 'Catégories' })
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
|
||||
const table = useDataTable(
|
||||
{ fetchData: fetchComposants },
|
||||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
|
||||
)
|
||||
|
||||
const columns = [
|
||||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'reference', label: 'Référence' },
|
||||
{ key: 'referenceAuto', label: 'Réf. auto' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||||
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||
{ key: 'actions', label: 'Actions' },
|
||||
]
|
||||
|
||||
const composantsOnPage = computed(() => componentRows.value.length)
|
||||
const paginationState = table.pagination(total, composantsOnPage)
|
||||
|
||||
const composantsList = computed(() => {
|
||||
return (composants.value || []).map((composant) => {
|
||||
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
||||
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
|
||||
})
|
||||
})
|
||||
|
||||
const componentRows = computed(() =>
|
||||
composantsList.value.map(component => ({
|
||||
id: component.id,
|
||||
component,
|
||||
})),
|
||||
)
|
||||
|
||||
async function fetchComposants() {
|
||||
await loadComposants({
|
||||
search: table.searchTerm.value,
|
||||
page: table.currentPage.value,
|
||||
itemsPerPage: table.itemsPerPage.value,
|
||||
orderBy: table.sortField.value,
|
||||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||||
typeName: table.columnFilters.value.typeComposant || undefined,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
const resolveComponentType = (component: Record<string, any>) => {
|
||||
if (component?.typeComposant?.name) return component.typeComposant.name
|
||||
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||||
return '—'
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||
const componentName = component?.name || 'ce composant'
|
||||
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
|
||||
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
|
||||
if (!confirmed) return
|
||||
await deleteComposant(component.id)
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const formatDate = formatFrenchDate
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||||
})
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user