Compare commits
71 Commits
| 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 |
@@ -7,6 +7,13 @@
|
|||||||
"X-Profile-Id": "admin-default-profile",
|
"X-Profile-Id": "admin-default-profile",
|
||||||
"X-Profile-Password": "A123"
|
"X-Profile-Password": "A123"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"lesstime": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://project.malio-dev.fr/_mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer b355c6cbf27d2a86d7eba1c3132c99bb3133f94cfd9e9243ffcc3c5ae1dc82c8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 import-data # Importer les dumps SQL normalisés
|
||||||
make cache-clear # Clear cache Symfony
|
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
|
# Release
|
||||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
./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
|
## Architecture Backend
|
||||||
|
|
||||||
### Entités Principales
|
### 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)
|
#### Entités de normalisation (slots & skeleton requirements)
|
||||||
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
|
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)`
|
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
|
||||||
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
|
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
|
||||||
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
|
- **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
|
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
|
||||||
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
|
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
|
||||||
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
|
- **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
|
### Pattern de test
|
||||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
||||||
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
|
- 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()`
|
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
||||||
|
|
||||||
## URLs Locales
|
## URLs Locales
|
||||||
@@ -264,3 +272,12 @@ make test-setup # Créer/mettre à jour le schéma test
|
|||||||
- Nuxt dev : `http://localhost:3001`
|
- Nuxt dev : `http://localhost:3001`
|
||||||
- Adminer (PG) : `http://localhost:5050`
|
- Adminer (PG) : `http://localhost:5050`
|
||||||
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
||||||
|
|
||||||
|
## Delegation Codex
|
||||||
|
|
||||||
|
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||||
|
|
||||||
|
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||||
|
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||||
|
|
||||||
|
C'est le meilleur ratio qualite/credits.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/mcp-bundle": "^0.6.0",
|
"symfony/mcp-bundle": "^0.6.0",
|
||||||
|
"symfony/mime": "8.0.*",
|
||||||
|
"symfony/monolog-bundle": "^4.0.2",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
"symfony/rate-limiter": "8.0.*",
|
"symfony/rate-limiter": "8.0.*",
|
||||||
|
|||||||
434
composer.lock
generated
434
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "2db01f705a09cf38007a2baa3b078e49",
|
"content-hash": "5c54b1589d9e815f4c9b7e5e1d2d69c7",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -2437,6 +2437,109 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-02-23T21:42:54+00:00"
|
"time": "2026-02-23T21:42:54+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "monolog/monolog",
|
||||||
|
"version": "3.10.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Seldaek/monolog.git",
|
||||||
|
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
||||||
|
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1",
|
||||||
|
"psr/log": "^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"psr/log-implementation": "3.0.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"aws/aws-sdk-php": "^3.0",
|
||||||
|
"doctrine/couchdb": "~1.0@dev",
|
||||||
|
"elasticsearch/elasticsearch": "^7 || ^8",
|
||||||
|
"ext-json": "*",
|
||||||
|
"graylog2/gelf-php": "^1.4.2 || ^2.0",
|
||||||
|
"guzzlehttp/guzzle": "^7.4.5",
|
||||||
|
"guzzlehttp/psr7": "^2.2",
|
||||||
|
"mongodb/mongodb": "^1.8 || ^2.0",
|
||||||
|
"php-amqplib/php-amqplib": "~2.4 || ^3",
|
||||||
|
"php-console/php-console": "^3.1.8",
|
||||||
|
"phpstan/phpstan": "^2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
|
||||||
|
"predis/predis": "^1.1 || ^2",
|
||||||
|
"rollbar/rollbar": "^4.0",
|
||||||
|
"ruflin/elastica": "^7 || ^8",
|
||||||
|
"symfony/mailer": "^5.4 || ^6",
|
||||||
|
"symfony/mime": "^5.4 || ^6"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
|
||||||
|
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
|
||||||
|
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
|
||||||
|
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
|
||||||
|
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
|
||||||
|
"ext-mbstring": "Allow to work properly with unicode symbols",
|
||||||
|
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
|
||||||
|
"ext-openssl": "Required to send log messages using SSL",
|
||||||
|
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
|
||||||
|
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
|
||||||
|
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
|
||||||
|
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
|
||||||
|
"rollbar/rollbar": "Allow sending log messages to Rollbar",
|
||||||
|
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Monolog\\": "src/Monolog"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "https://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
|
||||||
|
"homepage": "https://github.com/Seldaek/monolog",
|
||||||
|
"keywords": [
|
||||||
|
"log",
|
||||||
|
"logging",
|
||||||
|
"psr-3"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Seldaek/monolog/issues",
|
||||||
|
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Seldaek",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-01-02T08:56:05+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "nelmio/cors-bundle",
|
"name": "nelmio/cors-bundle",
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
@@ -5341,6 +5444,248 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-04T16:39:24+00:00"
|
"time": "2026-03-04T16:39:24+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/mime",
|
||||||
|
"version": "v8.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/mime.git",
|
||||||
|
"reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/mime/zipball/ddff21f14c7ce04b98101b399a9463dce8b0ce66",
|
||||||
|
"reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/polyfill-intl-idn": "^1.10",
|
||||||
|
"symfony/polyfill-mbstring": "^1.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"egulias/email-validator": "~3.0.0",
|
||||||
|
"phpdocumentor/reflection-docblock": "<5.2|>=7",
|
||||||
|
"phpdocumentor/type-resolver": "<1.5.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"egulias/email-validator": "^2.1.10|^3.1|^4",
|
||||||
|
"league/html-to-markdown": "^5.0",
|
||||||
|
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
|
||||||
|
"symfony/dependency-injection": "^7.4|^8.0",
|
||||||
|
"symfony/process": "^7.4|^8.0",
|
||||||
|
"symfony/property-access": "^7.4|^8.0",
|
||||||
|
"symfony/property-info": "^7.4|^8.0",
|
||||||
|
"symfony/serializer": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Mime\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Allows manipulating MIME messages",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"mime",
|
||||||
|
"mime-type"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/mime/tree/v8.0.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/monolog-bridge",
|
||||||
|
"version": "v8.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/monolog-bridge.git",
|
||||||
|
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c6efdcbd5cc17cf7618fb4447053b792df6ae724",
|
||||||
|
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"monolog/monolog": "^3",
|
||||||
|
"php": ">=8.4",
|
||||||
|
"symfony/http-kernel": "^7.4|^8.0",
|
||||||
|
"symfony/service-contracts": "^2.5|^3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/console": "^7.4|^8.0",
|
||||||
|
"symfony/http-client": "^7.4|^8.0",
|
||||||
|
"symfony/mailer": "^7.4|^8.0",
|
||||||
|
"symfony/messenger": "^7.4|^8.0",
|
||||||
|
"symfony/mime": "^7.4|^8.0",
|
||||||
|
"symfony/security-core": "^7.4|^8.0",
|
||||||
|
"symfony/var-dumper": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bridge",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Bridge\\Monolog\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides integration for Monolog with various Symfony components",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/monolog-bridge/tree/v8.0.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-03-30T15:14:47+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/monolog-bundle",
|
||||||
|
"version": "v4.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/monolog-bundle.git",
|
||||||
|
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/c012c6aba13129eb02aa7dd61e66e720911d8598",
|
||||||
|
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-runtime-api": "^2.0",
|
||||||
|
"monolog/monolog": "^3.5",
|
||||||
|
"php": ">=8.2",
|
||||||
|
"symfony/config": "^7.3 || ^8.0",
|
||||||
|
"symfony/dependency-injection": "^7.3 || ^8.0",
|
||||||
|
"symfony/http-kernel": "^7.3 || ^8.0",
|
||||||
|
"symfony/monolog-bridge": "^7.3 || ^8.0",
|
||||||
|
"symfony/polyfill-php84": "^1.30"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.5.41 || ^12.3",
|
||||||
|
"symfony/console": "^7.3 || ^8.0",
|
||||||
|
"symfony/yaml": "^7.3 || ^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Bundle\\MonologBundle\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony MonologBundle",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"log",
|
||||||
|
"logging"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/symfony/monolog-bundle/issues",
|
||||||
|
"source": "https://github.com/symfony/monolog-bundle/tree/v4.0.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-02T18:27:21+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/options-resolver",
|
"name": "symfony/options-resolver",
|
||||||
"version": "v8.0.0",
|
"version": "v8.0.0",
|
||||||
@@ -5567,6 +5912,93 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-06-27T09:58:17+00:00"
|
"time": "2025-06-27T09:58:17+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/polyfill-intl-idn",
|
||||||
|
"version": "v1.33.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/polyfill-intl-idn.git",
|
||||||
|
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||||
|
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.2",
|
||||||
|
"symfony/polyfill-intl-normalizer": "^1.10"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-intl": "For best performance"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/polyfill",
|
||||||
|
"name": "symfony/polyfill"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"bootstrap.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Polyfill\\Intl\\Idn\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Laurent Bassin",
|
||||||
|
"email": "laurent@bassin.info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Trevor Rowbotham",
|
||||||
|
"email": "trevor.rowbotham@pm.me"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"compatibility",
|
||||||
|
"idn",
|
||||||
|
"intl",
|
||||||
|
"polyfill",
|
||||||
|
"portable",
|
||||||
|
"shim"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-09-10T14:38:51+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-intl-normalizer",
|
"name": "symfony/polyfill-intl-normalizer",
|
||||||
"version": "v1.33.0",
|
"version": "v1.33.0",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
|||||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
use Symfony\AI\McpBundle\McpBundle;
|
use Symfony\AI\McpBundle\McpBundle;
|
||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
|
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||||
|
|
||||||
@@ -22,4 +23,5 @@ return [
|
|||||||
ApiPlatformBundle::class => ['all' => true],
|
ApiPlatformBundle::class => ['all' => true],
|
||||||
DAMADoctrineTestBundle::class => ['test' => true],
|
DAMADoctrineTestBundle::class => ['test' => true],
|
||||||
McpBundle::class => ['all' => true],
|
McpBundle::class => ['all' => true],
|
||||||
|
MonologBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
56
config/packages/monolog.yaml
Normal file
56
config/packages/monolog.yaml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
monolog:
|
||||||
|
channels:
|
||||||
|
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
|
||||||
|
|
||||||
|
when@dev:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
max_files: 7
|
||||||
|
channels: ["!event"]
|
||||||
|
console:
|
||||||
|
type: console
|
||||||
|
process_psr_3_messages: false
|
||||||
|
channels: ["!event", "!doctrine", "!console"]
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: fingers_crossed
|
||||||
|
action_level: error
|
||||||
|
handler: nested
|
||||||
|
excluded_http_codes: [404, 405]
|
||||||
|
channels: ["!event"]
|
||||||
|
nested:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
monolog:
|
||||||
|
handlers:
|
||||||
|
main:
|
||||||
|
type: fingers_crossed
|
||||||
|
action_level: error
|
||||||
|
handler: nested
|
||||||
|
excluded_http_codes: [404, 405]
|
||||||
|
channels: ["!deprecation"]
|
||||||
|
buffer_size: 50
|
||||||
|
nested:
|
||||||
|
type: rotating_file
|
||||||
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
|
level: debug
|
||||||
|
max_files: 30
|
||||||
|
console:
|
||||||
|
type: console
|
||||||
|
process_psr_3_messages: false
|
||||||
|
channels: ["!event", "!doctrine"]
|
||||||
|
deprecation:
|
||||||
|
type: rotating_file
|
||||||
|
channels: [deprecation]
|
||||||
|
path: "%kernel.logs_dir%/deprecations.log"
|
||||||
|
max_files: 7
|
||||||
@@ -69,3 +69,8 @@ when@test:
|
|||||||
autowire: true
|
autowire: true
|
||||||
autoconfigure: true
|
autoconfigure: true
|
||||||
public: true
|
public: true
|
||||||
|
|
||||||
|
App\Service\SkeletonStructureService:
|
||||||
|
autowire: true
|
||||||
|
autoconfigure: true
|
||||||
|
public: true
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '1.9.17'
|
app.version: '1.9.36'
|
||||||
|
|||||||
@@ -0,0 +1,926 @@
|
|||||||
|
# Custom Field Name Autocomplete — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Ajouter une autocomplétion sur les noms de champs personnalisés dans tous les éditeurs (machine + ModelType) pour permettre la réutilisation des noms existants tout en gardant la possibilité d'en créer de nouveaux.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- **Backend** : un endpoint utilitaire `GET /api/custom-fields/names` qui retourne la liste plate des noms distincts de la table `custom_fields`.
|
||||||
|
- **Frontend** : extension de `SearchSelect.vue` avec un prop `creatable`, composable `useCustomFieldNameSuggestions` avec cache module-level, composant wrapper `CustomFieldNameInput.vue`, migration de 4 éditeurs.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony 8 + API Platform + Doctrine DBAL, Nuxt 4 + Vue 3 Composition API + TypeScript, DaisyUI.
|
||||||
|
|
||||||
|
**Référence spec:** `docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — Controller `CustomFieldNamesController`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Controller/CustomFieldNamesController.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Créer le controller**
|
||||||
|
|
||||||
|
Créer `src/Controller/CustomFieldNamesController.php` avec le contenu :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
#[AsController]
|
||||||
|
final class CustomFieldNamesController
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Connection $connection)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(
|
||||||
|
path: '/api/custom-fields/names',
|
||||||
|
name: 'api_custom_fields_names',
|
||||||
|
methods: ['GET']
|
||||||
|
)]
|
||||||
|
#[IsGranted('ROLE_VIEWER')]
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
$sql = <<<'SQL'
|
||||||
|
SELECT DISTINCT name
|
||||||
|
FROM custom_fields
|
||||||
|
WHERE name IS NOT NULL AND name <> ''
|
||||||
|
ORDER BY name ASC
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$names = $this->connection->fetchFirstColumn($sql);
|
||||||
|
|
||||||
|
return new JsonResponse($names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier que la route est bien exposée**
|
||||||
|
|
||||||
|
Exécuter :
|
||||||
|
```bash
|
||||||
|
docker exec -u www-data php-inventory-apache php bin/console debug:router | grep custom-fields/names
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : une ligne contenant `GET /api/custom-fields/names` et `api_custom_fields_names`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Tester manuellement le endpoint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -b "$(curl -s -c - -X POST http://localhost:8081/api/session/profile \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"admin"}' | grep PHPSESSID)" \
|
||||||
|
http://localhost:8081/api/custom-fields/names | head -c 500
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si la session est galère à monter en curl, on peut tester via le navigateur après login (DevTools → fetch).
|
||||||
|
|
||||||
|
Attendu : un tableau JSON `["Numéro de série", "Tension", ...]` (ou `[]` si la base de dev est vide).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lancer php-cs-fixer**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make php-cs-fixer-allow-risky
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Controller/CustomFieldNamesController.php
|
||||||
|
git commit -m "feat(custom-fields) : ajoute endpoint GET /api/custom-fields/names
|
||||||
|
|
||||||
|
Retourne la liste plate des noms de champs perso distincts (table
|
||||||
|
custom_fields), pour alimenter une autocompletion cote frontend."
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Le pre-commit hook va lancer PHPUnit. Si des tests existants échouent (peu probable car on n'a touché à rien d'existant), résoudre le souci avant de continuer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend — Test PHPUnit du endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/Api/Controller/CustomFieldNamesControllerTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Repérer un test existant à copier-coller pour le style**
|
||||||
|
|
||||||
|
Lire `tests/Api/Controller/HealthCheckController*Test.php` ou un controller simple existant pour récupérer le pattern (auth helpers, `ApiTestCase`). Adapter selon ce qu'on trouve.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls tests/Api/Controller/ | head
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Créer le test**
|
||||||
|
|
||||||
|
Créer `tests/Api/Controller/CustomFieldNamesControllerTest.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Api\Controller;
|
||||||
|
|
||||||
|
use App\Tests\AbstractApiTestCase;
|
||||||
|
|
||||||
|
final class CustomFieldNamesControllerTest extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
public function testReturns401WhenUnauthenticated(): void
|
||||||
|
{
|
||||||
|
$client = $this->createUnauthenticatedClient();
|
||||||
|
$client->request('GET', '/api/custom-fields/names');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsEmptyArrayWhenNoCustomFields(): void
|
||||||
|
{
|
||||||
|
$client = $this->createViewerClient();
|
||||||
|
$client->request('GET', '/api/custom-fields/names');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
self::assertIsArray($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsDistinctSortedNames(): void
|
||||||
|
{
|
||||||
|
// Crée 3 machines avec des CustomField : "Tension", "Numéro de série", "Tension" (doublon)
|
||||||
|
$machine1 = $this->createMachine();
|
||||||
|
$this->createCustomField(['name' => 'Tension', 'machine' => $machine1]);
|
||||||
|
$this->createCustomField(['name' => 'Numéro de série', 'machine' => $machine1]);
|
||||||
|
|
||||||
|
$machine2 = $this->createMachine();
|
||||||
|
$this->createCustomField(['name' => 'Tension', 'machine' => $machine2]); // doublon
|
||||||
|
|
||||||
|
$client = $this->createViewerClient();
|
||||||
|
$client->request('GET', '/api/custom-fields/names');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
|
||||||
|
self::assertContains('Tension', $data);
|
||||||
|
self::assertContains('Numéro de série', $data);
|
||||||
|
// Pas de doublon
|
||||||
|
self::assertSame(count(array_unique($data)), count($data));
|
||||||
|
// Tri alpha
|
||||||
|
$sorted = $data;
|
||||||
|
sort($sorted, SORT_STRING);
|
||||||
|
self::assertSame($sorted, $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si la factory `createCustomField` n'a pas la signature attendue (1er argument = array), regarder `tests/AbstractApiTestCase.php` pour adapter aux helpers réels du projet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier que les helpers utilisés existent**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "createCustomField\|createMachine\|createViewerClient\|createUnauthenticatedClient" tests/AbstractApiTestCase.php | head
|
||||||
|
```
|
||||||
|
|
||||||
|
Si l'un des helpers manque ou a une autre signature, **adapter le test** plutôt que d'ajouter de nouveaux helpers.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Lancer le test ciblé**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test FILES=tests/Api/Controller/CustomFieldNamesControllerTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : 3 tests OK.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/Api/Controller/CustomFieldNamesControllerTest.php
|
||||||
|
git commit -m "test(custom-fields) : ajoute test PHPUnit pour endpoint /api/custom-fields/names"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — Étendre `SearchSelect.vue` avec le prop `creatable`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/components/common/SearchSelect.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ajouter le prop `creatable` au composant**
|
||||||
|
|
||||||
|
Dans le bloc `defineProps` (lignes ~91-141), ajouter après le prop `serverSearch` :
|
||||||
|
|
||||||
|
```js
|
||||||
|
creatable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Modifier `handleInput` pour emit en mode creatable**
|
||||||
|
|
||||||
|
Remplacer la fonction `handleInput` (lignes ~284-289) par :
|
||||||
|
|
||||||
|
```js
|
||||||
|
function handleInput () {
|
||||||
|
if (!openDropdown.value) {
|
||||||
|
openDropdown.value = true
|
||||||
|
}
|
||||||
|
if (props.creatable) {
|
||||||
|
emit('update:modelValue', searchTerm.value)
|
||||||
|
}
|
||||||
|
emit('search', searchTerm.value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Modifier `closeDropdown` pour ne pas reset en mode creatable**
|
||||||
|
|
||||||
|
Remplacer la fonction `closeDropdown` (lignes ~297-304) par :
|
||||||
|
|
||||||
|
```js
|
||||||
|
function closeDropdown () {
|
||||||
|
openDropdown.value = false
|
||||||
|
if (props.creatable) {
|
||||||
|
return // garde le texte tapé tel quel
|
||||||
|
}
|
||||||
|
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
} else if (selectedOption.value) {
|
||||||
|
searchTerm.value = resolveLabel(selectedOption.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Ajouter une computed `creatableSuggestion`**
|
||||||
|
|
||||||
|
Dans le bloc `<script setup>`, après la `computed displayedOptions` (ligne ~173), ajouter :
|
||||||
|
|
||||||
|
```js
|
||||||
|
const creatableSuggestion = computed(() => {
|
||||||
|
if (!props.creatable) return null
|
||||||
|
const term = searchTerm.value.trim()
|
||||||
|
if (!term) return null
|
||||||
|
// Affiche "Créer ..." uniquement si aucune option exacte ne matche (case-insensitive)
|
||||||
|
const exists = baseOptions.value.some(option => {
|
||||||
|
const label = resolveLabel(option).toLowerCase()
|
||||||
|
return label === term.toLowerCase()
|
||||||
|
})
|
||||||
|
return exists ? null : term
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Afficher la ligne "Créer ..." dans le template**
|
||||||
|
|
||||||
|
Localiser le bloc dropdown (lignes ~40-81). Juste **après** la `<ul>` qui contient les options (donc juste avant la fermeture du `<div v-if="openDropdown">`), ajouter :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<button
|
||||||
|
v-if="creatableSuggestion"
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
|
||||||
|
@click="confirmCreatable"
|
||||||
|
>
|
||||||
|
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
|
||||||
|
Créer « {{ creatableSuggestion }} »
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter l'import en haut :
|
||||||
|
```js
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Ajouter la fonction `confirmCreatable`**
|
||||||
|
|
||||||
|
Après `clearSelection` (ligne ~295), ajouter :
|
||||||
|
|
||||||
|
```js
|
||||||
|
function confirmCreatable () {
|
||||||
|
if (creatableSuggestion.value) {
|
||||||
|
emit('update:modelValue', creatableSuggestion.value)
|
||||||
|
}
|
||||||
|
openDropdown.value = false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Ajuster la sync `searchTerm` ↔ `modelValue` en mode creatable**
|
||||||
|
|
||||||
|
Localiser le `watch` sur `modelValue` (lignes ~194-202). Remplacer par :
|
||||||
|
|
||||||
|
```js
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
() => {
|
||||||
|
if (props.creatable) {
|
||||||
|
if (searchTerm.value !== props.modelValue) {
|
||||||
|
searchTerm.value = String(props.modelValue ?? '')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!openDropdown.value) {
|
||||||
|
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
> En mode creatable, `modelValue` et `searchTerm` reflètent la même chose (le texte tapé) — on évite juste la boucle infinie en testant l'égalité.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Vérifier le typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Lancer ESLint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Test de non-régression manuel**
|
||||||
|
|
||||||
|
Vérifier qu'un usage existant de `SearchSelect` (par exemple sur la page `frontend/app/pages/index.vue` ou similaire — chercher `<SearchSelect`) fonctionne toujours **sans** le prop `creatable` : le comportement strict doit être identique à avant.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && grep -rln "SearchSelect" app/ | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrir un de ces écrans en dev et vérifier que :
|
||||||
|
- La sélection d'une option marche
|
||||||
|
- Le blur sans sélection reset au label précédent (= mode strict inchangé)
|
||||||
|
|
||||||
|
- [ ] **Step 11: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/components/common/SearchSelect.vue
|
||||||
|
git commit -m "feat(search-select) : ajoute prop creatable pour autoriser la saisie libre
|
||||||
|
|
||||||
|
En mode creatable=true, le composant emit le texte tape en temps reel
|
||||||
|
et ne reset plus au blur. Une ligne 'Creer XYZ' apparait quand le texte
|
||||||
|
ne matche aucune option. Mode strict (defaut) inchange."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Frontend — Composable `useCustomFieldNameSuggestions`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/app/composables/useCustomFieldNameSuggestions.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Vérifier le pattern `useApi` existant**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat frontend/app/composables/useApi.ts | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
Noter la signature exacte (`<T>(path, opts?) => Promise<T>` ou autre) pour l'adapter.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Créer le composable**
|
||||||
|
|
||||||
|
Créer `frontend/app/composables/useCustomFieldNameSuggestions.ts` :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const cache = ref<string[] | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
api: ReturnType<typeof useApi>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomFieldNameSuggestions(deps: Deps) {
|
||||||
|
const { api } = deps
|
||||||
|
|
||||||
|
async function load(force = false): Promise<string[]> {
|
||||||
|
if (cache.value && !force) return cache.value
|
||||||
|
if (loading.value) return cache.value ?? []
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await api<string[]>('/api/custom-fields/names')
|
||||||
|
cache.value = Array.isArray(result) ? result : []
|
||||||
|
return cache.value
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[useCustomFieldNameSuggestions] failed to load', err)
|
||||||
|
cache.value = cache.value ?? []
|
||||||
|
return cache.value
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidate(): void {
|
||||||
|
cache.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions: cache,
|
||||||
|
loading,
|
||||||
|
load,
|
||||||
|
invalidate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note : `cache` et `loading` sont déclarés **au niveau du module** (hors de la fonction) → cache partagé entre toutes les instances.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier le typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : 0 errors. Si le `ReturnType<typeof useApi>` pose souci (selon comment `useApi` est typé), remplacer par un type explicite plus simple :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Deps {
|
||||||
|
api: <T>(path: string, opts?: RequestInit) => Promise<T>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/composables/useCustomFieldNameSuggestions.ts
|
||||||
|
git commit -m "feat(custom-fields) : ajoute composable useCustomFieldNameSuggestions
|
||||||
|
|
||||||
|
Cache module-level partage entre toutes les instances. Lazy load au
|
||||||
|
premier appel a load(). invalidate() permet de forcer un refresh apres
|
||||||
|
creation/modification d'un champ perso."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend — Composant wrapper `CustomFieldNameInput`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/app/components/common/CustomFieldNameInput.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Créer le composant**
|
||||||
|
|
||||||
|
Créer `frontend/app/components/common/CustomFieldNameInput.vue` :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<SearchSelect
|
||||||
|
:model-value="modelValue"
|
||||||
|
:options="options"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
option-value="name"
|
||||||
|
option-label="name"
|
||||||
|
creatable
|
||||||
|
:size="size"
|
||||||
|
@update:model-value="onUpdate"
|
||||||
|
@focus="ensureLoaded"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import SearchSelect from './SearchSelect.vue'
|
||||||
|
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
placeholder?: string
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||||
|
}>(), {
|
||||||
|
placeholder: 'Nom du champ',
|
||||||
|
size: 'xs',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { suggestions, load } = useCustomFieldNameSuggestions({ api: useApi() })
|
||||||
|
|
||||||
|
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
|
||||||
|
|
||||||
|
function ensureLoaded(): void {
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdate(value: string | number): void {
|
||||||
|
emit('update:modelValue', String(value ?? ''))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
> `SearchSelect` n'expose pas nativement un événement `@focus`. Vérifier dans la Task 3 si on l'a ajouté ou si on doit charger autrement.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Exposer `@focus` depuis `SearchSelect.vue`**
|
||||||
|
|
||||||
|
Retourner sur `SearchSelect.vue` et vérifier si `@focus` est propagé. Si non, modifier le handler `handleFocus` (ligne ~267-272) pour également émettre :
|
||||||
|
|
||||||
|
```js
|
||||||
|
const emit = defineEmits(['update:modelValue', 'search', 'focus'])
|
||||||
|
|
||||||
|
function handleFocus () {
|
||||||
|
openDropdown.value = true
|
||||||
|
if (searchTerm.value === '' && selectedOption.value) {
|
||||||
|
searchTerm.value = resolveLabel(selectedOption.value)
|
||||||
|
}
|
||||||
|
emit('focus')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter `'focus'` à la liste des emits si pas déjà présent.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier le typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Vérifier l'auto-import Nuxt**
|
||||||
|
|
||||||
|
Le composant étant dans `components/common/`, Nuxt devrait l'auto-importer. Vérifier après build :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Sans erreur de référence `CustomFieldNameInput is not defined` quand on l'utilisera dans les tâches suivantes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/components/common/CustomFieldNameInput.vue frontend/app/components/common/SearchSelect.vue
|
||||||
|
git commit -m "feat(custom-fields) : ajoute CustomFieldNameInput wrapper
|
||||||
|
|
||||||
|
Encapsule SearchSelect en mode creatable, branche useCustomFieldName-
|
||||||
|
Suggestions, charge la liste au focus. Permet de remplacer un simple
|
||||||
|
<input v-model='field.name'> par <CustomFieldNameInput v-model='field.name'>
|
||||||
|
dans les editeurs de champs perso."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Frontend — Migrer `MachineCustomFieldDefEditor.vue`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remplacer l'input du nom**
|
||||||
|
|
||||||
|
Dans `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`, localiser les lignes 36-41 :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<input
|
||||||
|
v-model="field.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
placeholder="Nom du champ"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remplacer par :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<CustomFieldNameInput
|
||||||
|
v-model="field.name"
|
||||||
|
placeholder="Nom du champ"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier le typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test manuel rapide**
|
||||||
|
|
||||||
|
Ouvrir une machine en édition, ajouter un champ perso, vérifier que l'input :
|
||||||
|
1. Affiche un dropdown au focus avec les noms existants
|
||||||
|
2. Filtre quand on tape
|
||||||
|
3. Sélection d'une suggestion → input rempli
|
||||||
|
4. Texte libre + blur → garde le texte tapé
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/components/machine/MachineCustomFieldDefEditor.vue
|
||||||
|
git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldDefEditor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Frontend — Migrer `MachineCustomFieldsCard.vue`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remplacer l'input du nom**
|
||||||
|
|
||||||
|
Dans `frontend/app/components/machine/MachineCustomFieldsCard.vue`, localiser les lignes ~53-59 :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<input
|
||||||
|
:value="field.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
placeholder="Nom du champ"
|
||||||
|
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Attention** : cet input utilise `:value` + `@blur` (pas `v-model`) parce qu'il déclenche une mise à jour seulement au blur (avec un appel API).
|
||||||
|
|
||||||
|
Remplacer par :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<CustomFieldNameInput
|
||||||
|
:model-value="field.name"
|
||||||
|
placeholder="Nom du champ"
|
||||||
|
size="sm"
|
||||||
|
@update:model-value="(value) => handleDefinitionUpdate(field, 'name', value)"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Le `@update:model-value` se déclenchera à chaque changement (donc à chaque caractère tapé en mode creatable). Si ce comportement génère trop d'appels API, on peut wrapper avec un `debounce` côté `handleDefinitionUpdate`. Pour l'instant, on garde simple.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier le comportement de `handleDefinitionUpdate`**
|
||||||
|
|
||||||
|
Vérifier que cette fonction est idempotente (rejouer un même nom = pas d'effet). Cherche la fonction dans le composant et confirme qu'elle compare l'ancienne/nouvelle valeur avant d'appeler l'API.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "handleDefinitionUpdate" frontend/app/components/machine/MachineCustomFieldsCard.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
Si elle ne dédoublonne pas, l'ajout d'un test rapide `if (field.name === value) return` peut éviter des PATCH inutiles.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier le typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test manuel**
|
||||||
|
|
||||||
|
Sur la page d'une machine, modifier inline un nom de champ perso → vérifier que ça déclenche un PATCH unique (DevTools Network).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/components/machine/MachineCustomFieldsCard.vue
|
||||||
|
git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldsCard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Frontend — Migrer `PieceModelStructureEditor.vue`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/components/PieceModelStructureEditor.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remplacer l'input du nom**
|
||||||
|
|
||||||
|
Dans `frontend/app/components/PieceModelStructureEditor.vue`, localiser les lignes 97-102 :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<input
|
||||||
|
v-model="field.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-xs"
|
||||||
|
placeholder="Nom du champ"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remplacer par :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<CustomFieldNameInput
|
||||||
|
v-model="field.name"
|
||||||
|
placeholder="Nom du champ"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier le typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test manuel**
|
||||||
|
|
||||||
|
Ouvrir un ModelType (catégorie composant ou skeleton), ajouter une pièce dans le skeleton, lui ajouter un champ perso → vérifier dropdown.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/components/PieceModelStructureEditor.vue
|
||||||
|
git commit -m "feat(custom-fields) : autocomplete sur le nom dans PieceModelStructureEditor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Frontend — Migrer `StructureNodeEditor.vue`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/components/StructureNodeEditor.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remplacer l'input du nom**
|
||||||
|
|
||||||
|
Dans `frontend/app/components/StructureNodeEditor.vue`, localiser les lignes 106-111 :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<input
|
||||||
|
v-model="field.name"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-xs"
|
||||||
|
placeholder="Nom du champ"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remplacer par :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<CustomFieldNameInput
|
||||||
|
v-model="field.name"
|
||||||
|
placeholder="Nom du champ"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier le typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test manuel**
|
||||||
|
|
||||||
|
Ouvrir un ModelType de catégorie machine, naviguer dans la structure (composants/sous-composants), ajouter un champ perso à un node → vérifier dropdown.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/components/StructureNodeEditor.vue
|
||||||
|
git commit -m "feat(custom-fields) : autocomplete sur le nom dans StructureNodeEditor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Frontend — Invalidation du cache après save
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/composables/useMachineCustomFieldDefs.ts`
|
||||||
|
- Modify: `frontend/app/components/model-types/ModelTypeForm.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Repérer le save dans `useMachineCustomFieldDefs.ts`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "POST\|PATCH\|api(" frontend/app/composables/useMachineCustomFieldDefs.ts | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Localiser la(es) fonction(s) qui sauvegarde les champs perso (probablement `saveDefinitions`, `addCustomFields`, etc.).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Ajouter l'invalidation après save**
|
||||||
|
|
||||||
|
Dans `useMachineCustomFieldDefs.ts`, en haut du fichier, après les imports existants :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions'
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans le corps du composable, ajouter (à placer près des autres `use*` calls) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() })
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis, après chaque save réussi (à la fin du `try` du POST/PATCH des definitions), appeler :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
invalidateCustomFieldNames()
|
||||||
|
```
|
||||||
|
|
||||||
|
> Identifier précisément les points de save dans le fichier — probablement 1 ou 2 endroits maximum.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Repérer le save dans `ModelTypeForm.vue`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "POST\|PATCH\|api(\|emit('saved')\|emit('save'" frontend/app/components/model-types/ModelTypeForm.vue | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Ajouter l'invalidation après save**
|
||||||
|
|
||||||
|
Dans le `<script setup>` de `ModelTypeForm.vue` :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
|
||||||
|
|
||||||
|
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() })
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis, après la sauvegarde réussie du ModelType (typiquement après le `await api(...)` qui POST/PATCH `/api/model_types/...`) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
invalidateCustomFieldNames()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Vérifier le typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Test manuel**
|
||||||
|
|
||||||
|
Scénario :
|
||||||
|
1. Ouvrir une machine, ajouter un champ perso « Test invalidation 2026 » et save.
|
||||||
|
2. Ouvrir une autre machine ou un ModelType.
|
||||||
|
3. Tenter d'ajouter un champ perso → taper « Test invalid » → vérifier que « Test invalidation 2026 » apparaît dans les suggestions.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/composables/useMachineCustomFieldDefs.ts frontend/app/components/model-types/ModelTypeForm.vue
|
||||||
|
git commit -m "feat(custom-fields) : invalide le cache de suggestions apres save
|
||||||
|
|
||||||
|
Apres chaque save reussi de champs perso (machine ou ModelType), on
|
||||||
|
invalide le cache useCustomFieldNameSuggestions pour que les noms
|
||||||
|
nouvellement crees apparaissent dans les futures autocomplete."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: Validation finale
|
||||||
|
|
||||||
|
**Files:** aucun changement, juste vérification end-to-end.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Lancer le typecheck complet**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer le linter complet**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : 0 errors (ou seulement des fixes auto).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test end-to-end manuel**
|
||||||
|
|
||||||
|
Démarrer l'environnement local (`make start` si pas déjà fait), puis :
|
||||||
|
|
||||||
|
1. **Machine** : créer une machine, ajouter 2 champs perso « Numéro de série » et « Tension ». Save.
|
||||||
|
2. **ModelType** : créer un ModelType de catégorie composant, ajouter une pièce dans le skeleton, ajouter à cette pièce un champ perso. Vérifier que « Numéro de série » et « Tension » apparaissent dans les suggestions.
|
||||||
|
3. **Structure** : créer un ModelType de catégorie machine, naviguer dans la structure, ajouter un champ perso à un composant. Vérifier les suggestions.
|
||||||
|
4. **Création libre** : taper un nom inédit, voir la ligne « Créer ... », cliquer ou faire blur → garder le texte.
|
||||||
|
5. **Sélection** : cliquer sur une suggestion → input se remplit avec le nom exact.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Vérifier le commit log**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -15
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirmer qu'on a bien 1 commit par task, avec des messages cohérents.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Push (optionnel, à confirmer avec l'utilisateur)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ Ne PAS push sans demande explicite de l'utilisateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Récapitulatif des fichiers
|
||||||
|
|
||||||
|
### Créés
|
||||||
|
- `src/Controller/CustomFieldNamesController.php`
|
||||||
|
- `tests/Api/Controller/CustomFieldNamesControllerTest.php`
|
||||||
|
- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
|
||||||
|
- `frontend/app/components/common/CustomFieldNameInput.vue`
|
||||||
|
|
||||||
|
### Modifiés
|
||||||
|
- `frontend/app/components/common/SearchSelect.vue`
|
||||||
|
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
|
||||||
|
- `frontend/app/components/machine/MachineCustomFieldsCard.vue`
|
||||||
|
- `frontend/app/components/PieceModelStructureEditor.vue`
|
||||||
|
- `frontend/app/components/StructureNodeEditor.vue`
|
||||||
|
- `frontend/app/composables/useMachineCustomFieldDefs.ts`
|
||||||
|
- `frontend/app/components/model-types/ModelTypeForm.vue`
|
||||||
@@ -0,0 +1,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)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
<!-- Root Components -->
|
<!-- Root Components -->
|
||||||
<div v-for="component in components" :key="component.id" class="border border-gray-200 rounded-lg p-4">
|
<div v-for="component in components" :key="component.id">
|
||||||
<ComponentItem
|
<ComponentItem
|
||||||
:component="component"
|
:component="component"
|
||||||
:is-edit-mode="isEditMode"
|
:is-edit-mode="isEditMode"
|
||||||
|
|||||||
@@ -13,29 +13,42 @@
|
|||||||
@updated="handleDocumentUpdated"
|
@updated="handleDocumentUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Component Header -->
|
<!-- ═══ HEADER BAR ═══ -->
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-shadow"
|
class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
|
||||||
:class="[
|
:class="[
|
||||||
component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200',
|
component.pendingEntity
|
||||||
!isCollapsed ? 'sticky top-16 z-10 shadow-sm' : '',
|
? '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"
|
@click="toggleCollapse"
|
||||||
>
|
>
|
||||||
<IconLucideChevronRight
|
<!-- Chevron -->
|
||||||
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
|
<div
|
||||||
:class="{ 'rotate-90': !isCollapsed }"
|
class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
|
||||||
aria-hidden="true"
|
:class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
|
||||||
/>
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<IconLucideChevronRight
|
||||||
|
class="w-3.5 h-3.5 transition-transform duration-200"
|
||||||
|
:class="[
|
||||||
|
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
|
||||||
|
]"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<!-- Row 1: Name + identifiers -->
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
|
<h3 class="text-sm font-bold tracking-tight truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="!isEditMode && !component.pendingEntity && component.composantId"
|
v-if="!isEditMode && !component.pendingEntity && component.composantId"
|
||||||
:to="machineId
|
:to="machineId
|
||||||
? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } }
|
? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } }
|
||||||
: `/component/${component.composantId}`"
|
: `/component/${component.composantId}`"
|
||||||
class="hover:underline hover:text-primary transition-colors"
|
class="hover:text-primary transition-colors"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
{{ component.name }}
|
{{ component.name }}
|
||||||
@@ -51,232 +64,287 @@
|
|||||||
>
|
>
|
||||||
À remplir
|
À remplir
|
||||||
</button>
|
</button>
|
||||||
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
|
<span v-if="component.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ component.reference }}</span>
|
||||||
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}€</span>
|
<span v-if="component.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ component.referenceAuto }}</span>
|
||||||
|
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}€</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
|
<!-- Row 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
|
<span
|
||||||
v-for="constructeur in componentConstructeursDisplay"
|
v-for="constructeur in componentConstructeursDisplay"
|
||||||
:key="constructeur.id"
|
:key="constructeur.id"
|
||||||
class="text-xs text-base-content/50"
|
class="text-[0.65rem] text-base-content/45"
|
||||||
>
|
>
|
||||||
{{ constructeur.name }}
|
{{ constructeur.name }}
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="displayProductName" class="badge badge-info badge-xs">
|
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
|
||||||
{{ displayProductName }}
|
{{ displayProductName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete button -->
|
||||||
<button
|
<button
|
||||||
v-if="showDelete"
|
v-if="showDelete"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
|
||||||
title="Supprimer ce composant"
|
title="Supprimer ce composant"
|
||||||
@click.stop="$emit('delete')"
|
@click.stop="$emit('delete')"
|
||||||
>
|
>
|
||||||
Supprimer
|
<IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expanded content -->
|
<!-- ═══ EXPANDED PANEL ═══ -->
|
||||||
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7">
|
<div v-show="!isCollapsed && !component.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
|
||||||
<!-- Info fields -->
|
|
||||||
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<!-- ── Section: Informations ── -->
|
||||||
<div class="form-control">
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="p-4">
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
|
<!-- Edit mode -->
|
||||||
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Nom</span></label>
|
||||||
|
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
|
||||||
|
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
|
||||||
|
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
|
||||||
|
<ConstructeurSelect
|
||||||
|
class="w-full"
|
||||||
|
:model-value="componentConstructeurIds"
|
||||||
|
:initial-options="componentConstructeursDisplay"
|
||||||
|
@update:model-value="handleConstructeurChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Read-only mode -->
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Nom</p>
|
||||||
|
<p class="text-sm text-base-content font-medium">{{ component.name }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
|
||||||
|
<p class="text-sm text-base-content" :class="component.reference ? 'font-mono' : 'text-base-content/30'">{{ component.reference || '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="component.referenceAuto">
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
|
||||||
|
<p class="text-sm text-base-content font-mono">{{ component.referenceAuto }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
|
||||||
|
<p class="text-sm" :class="component.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ component.prix ? `${component.prix} €` : '—' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
|
||||||
|
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
|
||||||
|
<p
|
||||||
|
v-for="constructeur in componentConstructeursDisplay"
|
||||||
|
:key="constructeur.id"
|
||||||
|
class="text-sm text-base-content"
|
||||||
|
>
|
||||||
|
{{ constructeur.name }}
|
||||||
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
|
||||||
|
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
|
||||||
|
{{ formatConstructeurContact(constructeur) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-base-content/30">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
</div>
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
|
|
||||||
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
|
<!-- ── Section: Produit catalogue ── -->
|
||||||
|
<div v-if="displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-info/10 border-b border-info/20">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="p-4">
|
||||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
|
<div class="flex items-start justify-between gap-3">
|
||||||
<ConstructeurSelect
|
<div class="space-y-1.5">
|
||||||
class="w-full"
|
<p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
|
||||||
:model-value="componentConstructeurIds"
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
:initial-options="componentConstructeursDisplay"
|
<p
|
||||||
@update:model-value="handleConstructeurChange"
|
v-for="info in productInfoRows"
|
||||||
|
:key="info.label"
|
||||||
|
class="text-xs text-base-content/55"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="component.product?.id"
|
||||||
|
:to="`/product/${component.product.id}`"
|
||||||
|
class="btn btn-ghost btn-xs shrink-0 gap-1"
|
||||||
|
>
|
||||||
|
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
|
||||||
|
Voir
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<!-- Product documents -->
|
||||||
|
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200/50 space-y-2">
|
||||||
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/35">Documents du produit</p>
|
||||||
|
<div
|
||||||
|
v-for="document in productDocuments"
|
||||||
|
:key="document.id || document.path || document.name"
|
||||||
|
class="flex items-center justify-between gap-3 text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
|
||||||
|
<img
|
||||||
|
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||||
|
:src="document.fileUrl || document.path"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:alt="`Aperçu de ${document.name}`"
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
v-else-if="shouldInlinePdf(document)"
|
||||||
|
:src="documentPreviewSrc(document)"
|
||||||
|
class="h-full w-full border-0 bg-white"
|
||||||
|
title="Aperçu PDF"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
:is="documentIcon(document).component"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:class="documentIcon(document).colorClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="truncate text-base-content">{{ document.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
:disabled="!canPreviewDocument(document)"
|
||||||
|
@click="openPreview(document)"
|
||||||
|
>
|
||||||
|
Consulter
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Section: Champs personnalisés ── -->
|
||||||
|
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<CustomFieldDisplay
|
||||||
|
:fields="displayedCustomFields"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:columns="2"
|
||||||
|
:show-header="false"
|
||||||
|
:with-top-border="false"
|
||||||
|
:editable="false"
|
||||||
|
@field-blur="updateComponentCustomField"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Read-only info -->
|
<div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
|
<div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
|
||||||
<div>
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
|
|
||||||
<p class="text-base-content">{{ component.name }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="p-4">
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Référence</p>
|
<CustomFieldDisplay
|
||||||
<p class="text-base-content">{{ component.reference || '—' }}</p>
|
:fields="mergedContextFields"
|
||||||
</div>
|
:is-edit-mode="isEditMode"
|
||||||
<div>
|
:columns="2"
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Prix</p>
|
:show-header="false"
|
||||||
<p class="text-base-content">{{ component.prix ? `${component.prix} €` : '—' }}</p>
|
:with-top-border="false"
|
||||||
</div>
|
:editable="true"
|
||||||
<div>
|
:emit-blur="false"
|
||||||
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p>
|
@field-input="queueContextCustomFieldUpdate"
|
||||||
<div v-if="componentConstructeursDisplay.length">
|
/>
|
||||||
<p
|
|
||||||
v-for="constructeur in componentConstructeursDisplay"
|
|
||||||
:key="constructeur.id"
|
|
||||||
class="text-base-content"
|
|
||||||
>
|
|
||||||
{{ constructeur.name }}
|
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
|
|
||||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
|
|
||||||
{{ formatConstructeurContact(constructeur) }}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-base-content">—</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product -->
|
<!-- ── Section: Documents ── -->
|
||||||
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3">
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
|
||||||
<div class="space-y-1">
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
|
||||||
<p class="text-xs text-base-content/40">Produit catalogue</p>
|
|
||||||
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
|
|
||||||
<p
|
|
||||||
v-for="info in productInfoRows"
|
|
||||||
:key="info.label"
|
|
||||||
class="text-xs text-base-content/60"
|
|
||||||
>
|
|
||||||
{{ info.label }} : {{ info.value }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="component.product?.id"
|
|
||||||
:to="`/product/${component.product.id}`"
|
|
||||||
class="btn btn-ghost btn-xs shrink-0"
|
|
||||||
>
|
|
||||||
Voir le produit
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
<!-- Product documents -->
|
|
||||||
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
|
|
||||||
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
|
|
||||||
<div
|
|
||||||
v-for="document in productDocuments"
|
|
||||||
:key="document.id || document.path || document.name"
|
|
||||||
class="flex items-center justify-between gap-3 text-xs"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
|
|
||||||
<img
|
|
||||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
|
||||||
:src="document.fileUrl || document.path"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
:alt="`Aperçu de ${document.name}`"
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
v-else-if="shouldInlinePdf(document)"
|
|
||||||
:src="documentPreviewSrc(document)"
|
|
||||||
class="h-full w-full border-0 bg-white"
|
|
||||||
title="Aperçu PDF"
|
|
||||||
/>
|
|
||||||
<component
|
|
||||||
v-else
|
|
||||||
:is="documentIcon(document).component"
|
|
||||||
class="h-4 w-4"
|
|
||||||
:class="documentIcon(document).colorClass"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="truncate text-base-content">{{ document.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
:disabled="!canPreviewDocument(document)"
|
|
||||||
@click="openPreview(document)"
|
|
||||||
>
|
|
||||||
Consulter
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
|
||||||
Télécharger
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom Fields -->
|
|
||||||
<CustomFieldDisplay
|
|
||||||
:fields="displayedCustomFields"
|
|
||||||
:is-edit-mode="isEditMode"
|
|
||||||
:columns="2"
|
|
||||||
title="Champs personnalisés item"
|
|
||||||
:editable="false"
|
|
||||||
@field-blur="updateComponentCustomField"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="mergedContextFields.length">
|
|
||||||
<div class="divider my-4 text-xs text-base-content/50">
|
|
||||||
Champs personnalisés machine
|
|
||||||
</div>
|
|
||||||
<CustomFieldDisplay
|
|
||||||
:fields="mergedContextFields"
|
|
||||||
:is-edit-mode="isEditMode"
|
|
||||||
:columns="2"
|
|
||||||
:show-header="false"
|
|
||||||
:with-top-border="false"
|
|
||||||
:editable="true"
|
|
||||||
:emit-blur="false"
|
|
||||||
@field-input="queueContextCustomFieldUpdate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Documents -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
|
|
||||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
||||||
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement...</p>
|
||||||
|
|
||||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
<DocumentUpload
|
||||||
Chargement...
|
v-if="isEditMode"
|
||||||
</p>
|
v-model="selectedFiles"
|
||||||
|
title="Déposer des fichiers pour ce composant"
|
||||||
|
subtitle="Formats acceptés : PDF, images, documents..."
|
||||||
|
@files-added="handleFilesAdded"
|
||||||
|
/>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentListInline
|
||||||
v-if="isEditMode"
|
:documents="componentDocuments"
|
||||||
v-model="selectedFiles"
|
:can-delete="isEditMode"
|
||||||
title="Déposer des fichiers pour ce composant"
|
:can-edit="isEditMode"
|
||||||
subtitle="Formats acceptés : PDF, images, documents..."
|
:delete-disabled="uploadingDocuments"
|
||||||
@files-added="handleFilesAdded"
|
empty-text="Aucun document lié à ce composant."
|
||||||
/>
|
@preview="openPreview"
|
||||||
|
@edit="openEditModal"
|
||||||
<DocumentListInline
|
@delete="removeDocument"
|
||||||
:documents="componentDocuments"
|
/>
|
||||||
:can-delete="isEditMode"
|
</div>
|
||||||
:can-edit="isEditMode"
|
|
||||||
:delete-disabled="uploadingDocuments"
|
|
||||||
empty-text="Aucun document lié à ce composant."
|
|
||||||
@preview="openPreview"
|
|
||||||
@edit="openEditModal"
|
|
||||||
@delete="removeDocument"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Component Pieces (real MachinePieceLinks) -->
|
<!-- ── Section: Pièces du composant ── -->
|
||||||
<div v-if="linkedPieces.length > 0" class="space-y-2">
|
<div v-if="linkedPieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
Pièces du composant
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
|
||||||
</p>
|
Pièces du composant
|
||||||
<div class="space-y-2">
|
<span class="ml-1 text-base-content/25">({{ linkedPieces.length }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
<PieceItem
|
<PieceItem
|
||||||
v-for="piece in linkedPieces"
|
v-for="piece in linkedPieces"
|
||||||
:key="piece.id"
|
:key="piece.id"
|
||||||
@@ -290,12 +358,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Structure pieces (read-only, from composant definition) -->
|
<!-- ── Section: Pièces structure ── -->
|
||||||
<div v-if="structurePieces.length > 0" class="space-y-2">
|
<div v-if="structurePieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
Pièces incluses par défaut
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
|
||||||
</p>
|
Pièces incluses par défaut
|
||||||
<div class="space-y-2">
|
<span class="ml-1 text-base-content/25">({{ structurePieces.length }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
<PieceItem
|
<PieceItem
|
||||||
v-for="piece in structurePieces"
|
v-for="piece in structurePieces"
|
||||||
:key="piece.id"
|
:key="piece.id"
|
||||||
@@ -305,12 +376,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sub Components -->
|
<!-- ── Section: Sous-composants ── -->
|
||||||
<div v-if="childComponents.length > 0" class="space-y-2">
|
<div v-if="childComponents.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
Sous-composants
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
|
||||||
</p>
|
Sous-composants
|
||||||
<div class="space-y-2 pl-4 border-l-2 border-base-200">
|
<span class="ml-1 text-base-content/25">({{ childComponents.length }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-2">
|
||||||
<ComponentItem
|
<ComponentItem
|
||||||
v-for="subComponent in childComponents"
|
v-for="subComponent in childComponents"
|
||||||
:key="subComponent.id"
|
:key="subComponent.id"
|
||||||
@@ -336,6 +410,8 @@ import DocumentUpload from './DocumentUpload.vue'
|
|||||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
import IconLucideExternalLink from '~icons/lucide/external-link'
|
||||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import {
|
import {
|
||||||
@@ -461,6 +537,24 @@ const mergedContextFields = computed(() => {
|
|||||||
return mergeDefinitionsWithValues(definitions, values)
|
return mergeDefinitionsWithValues(definitions, values)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Context fields shown as tags on the header (consultation mode)
|
||||||
|
const visibleContextFieldTags = computed(() =>
|
||||||
|
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CONTEXT_FIELD_COLORS = [
|
||||||
|
'bg-secondary/25 text-secondary border border-secondary/35',
|
||||||
|
'bg-accent/25 text-accent border border-accent/35',
|
||||||
|
'bg-info/25 text-info border border-info/35',
|
||||||
|
'bg-success/25 text-success border border-success/35',
|
||||||
|
'bg-warning/25 text-warning border border-warning/35',
|
||||||
|
]
|
||||||
|
|
||||||
|
const contextFieldBadgeClass = (field: any) => {
|
||||||
|
const idx = visibleContextFieldTags.value.indexOf(field)
|
||||||
|
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
const queueContextCustomFieldUpdate = (field, value) => {
|
const queueContextCustomFieldUpdate = (field, value) => {
|
||||||
const linkId = props.component?.linkId
|
const linkId = props.component?.linkId
|
||||||
if (!linkId || !field) return
|
if (!linkId || !field) return
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ import IconLucideCheck from '~icons/lucide/check'
|
|||||||
import IconLucideX from '~icons/lucide/x'
|
import IconLucideX from '~icons/lucide/x'
|
||||||
import {
|
import {
|
||||||
type ConstructeurSummary,
|
type ConstructeurSummary,
|
||||||
|
constructeurPhones,
|
||||||
formatConstructeurContact,
|
formatConstructeurContact,
|
||||||
resolveConstructeurs,
|
resolveConstructeurs,
|
||||||
uniqueConstructeurIds,
|
uniqueConstructeurIds,
|
||||||
@@ -193,7 +194,7 @@ const filteredOptions = computed(() => {
|
|||||||
return options.value.filter((option) =>
|
return options.value.filter((option) =>
|
||||||
(option.name ?? '').toLowerCase().includes(term)
|
(option.name ?? '').toLowerCase().includes(term)
|
||||||
|| (option.email && option.email.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
|
creating.value = true
|
||||||
const payload: { name: string; email?: string; phone?: string } = {
|
const payload: { name: string; email?: string; telephones?: Array<{ numero: string }> } = {
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
}
|
}
|
||||||
if (createForm.value.email) {
|
if (createForm.value.email) {
|
||||||
payload.email = createForm.value.email
|
payload.email = createForm.value.email
|
||||||
}
|
}
|
||||||
if (createForm.value.phone) {
|
if (createForm.value.phone && createForm.value.phone.trim()) {
|
||||||
payload.phone = createForm.value.phone
|
payload.telephones = [{ numero: createForm.value.phone.trim() }]
|
||||||
}
|
}
|
||||||
const result = await createConstructeur(payload)
|
const result = await createConstructeur(payload)
|
||||||
creating.value = false
|
creating.value = false
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div>
|
||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
@@ -13,303 +13,338 @@
|
|||||||
@updated="handleDocumentUpdated"
|
@updated="handleDocumentUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
<!-- ═══ HEADER BAR ═══ -->
|
||||||
<div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot || piece.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'">
|
<div
|
||||||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
|
||||||
<button
|
:class="[
|
||||||
type="button"
|
piece._emptySlot || piece.pendingEntity
|
||||||
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
? 'bg-error/10 border border-error/40 hover:border-error/60'
|
||||||
:class="{ 'rotate-90': !isCollapsed }"
|
: 'bg-base-200/70 border border-base-300/30 hover:border-base-300/60 hover:bg-base-200',
|
||||||
:aria-expanded="!isCollapsed"
|
!isCollapsed ? 'shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
|
||||||
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
|
]"
|
||||||
@click="toggleCollapse"
|
@click="toggleCollapse"
|
||||||
>
|
>
|
||||||
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
<!-- Chevron -->
|
||||||
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
|
<div
|
||||||
</button>
|
class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
|
||||||
<div class="flex-1 min-w-0">
|
:class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
|
||||||
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
|
>
|
||||||
|
<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
|
<NuxtLink
|
||||||
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
|
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
|
||||||
:to="machineId
|
:to="machineId
|
||||||
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
|
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
|
||||||
: `/piece/${piece.pieceId}`"
|
: `/piece/${piece.pieceId}`"
|
||||||
class="hover:underline hover:text-primary transition-colors"
|
class="hover:text-primary transition-colors"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
{{ pieceData.name }}
|
{{ pieceData.name }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<template v-else>{{ pieceData.name }}</template>
|
<template v-else>{{ pieceData.name }}</template>
|
||||||
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1">— manquant</span>
|
|
||||||
<button
|
|
||||||
v-if="piece.pendingEntity"
|
|
||||||
type="button"
|
|
||||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors ml-1"
|
|
||||||
title="Cliquer pour associer un item"
|
|
||||||
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
|
|
||||||
>
|
|
||||||
À remplir
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
v-if="displayQuantity > 1"
|
|
||||||
class="text-sm font-normal text-base-content/60 ml-1"
|
|
||||||
>
|
|
||||||
×{{ displayQuantity }}
|
|
||||||
</span>
|
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<span v-if="piece._emptySlot" class="text-[0.65rem] font-bold text-error bg-error/10 px-1.5 py-0.5 rounded">manquant</span>
|
||||||
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
|
<button
|
||||||
Rattachée à {{ piece.parentComponentName }}
|
v-if="piece.pendingEntity"
|
||||||
</span>
|
type="button"
|
||||||
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
|
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
|
||||||
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
title="Cliquer pour associer un item"
|
||||||
<template v-if="pieceConstructeursDisplay.length">
|
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
|
||||||
<span
|
>
|
||||||
v-for="constructeur in pieceConstructeursDisplay"
|
À remplir
|
||||||
:key="constructeur.id"
|
</button>
|
||||||
class="badge badge-outline badge-sm"
|
<span v-if="displayQuantity > 1" class="text-[0.65rem] font-bold text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">×{{ displayQuantity }}</span>
|
||||||
>
|
<span v-if="pieceData.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ pieceData.reference }}</span>
|
||||||
{{ constructeur.name }}
|
<span v-if="pieceData.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
|
<span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}€</span>
|
||||||
({{ supplierReferenceMap.get(constructeur.id) }})
|
</div>
|
||||||
</span>
|
|
||||||
</span>
|
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
|
||||||
</template>
|
<div
|
||||||
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}€</span>
|
v-if="visibleContextFieldTags.length"
|
||||||
<span
|
class="flex flex-wrap items-center gap-2"
|
||||||
v-if="displayProductName"
|
>
|
||||||
class="badge badge-info badge-sm"
|
<span
|
||||||
>
|
v-for="field in visibleContextFieldTags"
|
||||||
Produit : {{ displayProductName }}
|
:key="field.name"
|
||||||
</span>
|
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
|
||||||
</div>
|
: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete button -->
|
||||||
<button
|
<button
|
||||||
v-if="showDelete"
|
v-if="showDelete"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
|
||||||
title="Supprimer cette pièce"
|
title="Supprimer cette pièce"
|
||||||
@click="$emit('delete')"
|
@click.stop="$emit('delete')"
|
||||||
>
|
>
|
||||||
Supprimer
|
<IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4">
|
<!-- ═══ EXPANDED PANEL ═══ -->
|
||||||
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
|
<div v-show="!isCollapsed && !piece.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<div v-if="isEditMode" class="form-control">
|
<!-- ── Section: Informations ── -->
|
||||||
<label class="label">
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
<span class="label-text text-sm">Quantité</span>
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
</label>
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
|
||||||
<input
|
</div>
|
||||||
v-model.number="pieceData.quantity"
|
<div class="p-4">
|
||||||
type="number"
|
<!-- Edit mode -->
|
||||||
min="1"
|
<div v-if="isEditMode" class="space-y-3">
|
||||||
step="1"
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
class="input input-bordered input-sm md:input-md w-24"
|
<div class="form-control">
|
||||||
@blur="updatePiece"
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Quantité</span></label>
|
||||||
/>
|
<input
|
||||||
</div>
|
v-model.number="pieceData.quantity"
|
||||||
<div v-else-if="displayQuantity > 1">
|
type="number"
|
||||||
<span class="font-medium">Quantité:</span>
|
min="1"
|
||||||
<span class="ml-2">{{ displayQuantity }}</span>
|
step="1"
|
||||||
</div>
|
class="input input-bordered input-sm w-full"
|
||||||
<div>
|
@blur="updatePiece"
|
||||||
<span class="font-medium">Référence:</span>
|
/>
|
||||||
<input
|
</div>
|
||||||
v-if="isEditMode"
|
<div class="form-control">
|
||||||
:id="`piece-reference-${piece.id}`"
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
|
||||||
v-model="pieceData.reference"
|
<input
|
||||||
type="text"
|
:id="`piece-reference-${piece.id}`"
|
||||||
class="input input-sm input-bordered ml-2"
|
v-model="pieceData.reference"
|
||||||
@blur="updatePiece"
|
type="text"
|
||||||
/>
|
class="input input-bordered input-sm w-full"
|
||||||
<span v-else class="ml-2">{{
|
@blur="updatePiece"
|
||||||
pieceData.reference || "Non définie"
|
/>
|
||||||
}}</span>
|
</div>
|
||||||
</div>
|
<div class="form-control">
|
||||||
<div v-if="pieceData.referenceAuto">
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
|
||||||
<span class="font-medium">Référence auto:</span>
|
<input
|
||||||
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
|
:id="`piece-prix-${piece.id}`"
|
||||||
</div>
|
v-model="pieceData.prix"
|
||||||
<div>
|
type="number"
|
||||||
<span class="font-medium">Fournisseur:</span>
|
step="0.01"
|
||||||
<div v-if="!isEditMode" class="ml-2">
|
class="input input-bordered input-sm w-full"
|
||||||
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
|
@blur="updatePiece"
|
||||||
<div
|
/>
|
||||||
v-for="constructeur in pieceConstructeursDisplay"
|
</div>
|
||||||
:key="constructeur.id"
|
</div>
|
||||||
class="flex flex-col"
|
<div class="form-control">
|
||||||
>
|
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
|
||||||
<span class="font-medium">
|
<ConstructeurSelect
|
||||||
{{ constructeur.name }}
|
class="w-full"
|
||||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60">
|
:model-value="pieceConstructeurIds"
|
||||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
:initial-options="pieceConstructeursDisplay"
|
||||||
</span>
|
placeholder="Sélectionner un ou plusieurs fournisseurs..."
|
||||||
</span>
|
@update:model-value="handleConstructeurChange"
|
||||||
<span
|
/>
|
||||||
v-if="formatConstructeurContact(constructeur)"
|
|
||||||
class="text-xs text-base-content/50"
|
|
||||||
>
|
|
||||||
{{ formatConstructeurContact(constructeur) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="font-medium">
|
<!-- Read-only mode -->
|
||||||
Non défini
|
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
|
||||||
</span>
|
<div v-if="displayQuantity > 1">
|
||||||
</div>
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Quantité</p>
|
||||||
<ConstructeurSelect
|
<p class="text-sm text-base-content font-medium">{{ displayQuantity }}</p>
|
||||||
v-else
|
</div>
|
||||||
class="w-full"
|
<div>
|
||||||
:model-value="pieceConstructeurIds"
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
|
||||||
:initial-options="pieceConstructeursDisplay"
|
<p class="text-sm" :class="pieceData.reference ? 'text-base-content font-mono' : 'text-base-content/30'">{{ pieceData.reference || '—' }}</p>
|
||||||
placeholder="Sélectionner un ou plusieurs fournisseurs..."
|
</div>
|
||||||
@update:model-value="handleConstructeurChange"
|
<div v-if="pieceData.referenceAuto">
|
||||||
/>
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
|
||||||
</div>
|
<p class="text-sm text-base-content font-mono">{{ pieceData.referenceAuto }}</p>
|
||||||
<div>
|
</div>
|
||||||
<span class="font-medium">Prix:</span>
|
<div>
|
||||||
<input
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
|
||||||
v-if="isEditMode"
|
<p class="text-sm" :class="pieceData.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ pieceData.prix ? `${pieceData.prix} €` : '—' }}</p>
|
||||||
:id="`piece-prix-${piece.id}`"
|
</div>
|
||||||
v-model="pieceData.prix"
|
<div>
|
||||||
type="number"
|
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
|
||||||
step="0.01"
|
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
|
||||||
class="input input-sm input-bordered ml-2"
|
<p
|
||||||
@blur="updatePiece"
|
v-for="constructeur in pieceConstructeursDisplay"
|
||||||
/>
|
:key="constructeur.id"
|
||||||
<span v-else class="ml-2">{{
|
class="text-sm text-base-content"
|
||||||
pieceData.prix ? `${pieceData.prix}€` : "Non défini"
|
>
|
||||||
}}</span>
|
{{ constructeur.name }}
|
||||||
</div>
|
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
|
||||||
<div>
|
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||||
<span class="font-medium">Produit catalogue:</span>
|
</span>
|
||||||
<div v-if="isEditMode" class="mt-2 space-y-2">
|
<span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
|
||||||
<ProductSelect
|
{{ formatConstructeurContact(constructeur) }}
|
||||||
:model-value="pieceData.productId"
|
</span>
|
||||||
placeholder="Associer un produit…"
|
</p>
|
||||||
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
|
</div>
|
||||||
@update:modelValue="handleProductChange"
|
<p v-else class="text-sm text-base-content/30">—</p>
|
||||||
/>
|
</div>
|
||||||
<div
|
|
||||||
v-if="selectedProduct"
|
|
||||||
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
|
|
||||||
>
|
|
||||||
<p class="text-sm font-semibold text-base-content">
|
|
||||||
{{ selectedProduct.name }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-for="info in productInfoRows"
|
|
||||||
:key="info.label"
|
|
||||||
class="flex flex-wrap gap-1"
|
|
||||||
>
|
|
||||||
<span class="font-semibold">{{ info.label }} :</span>
|
|
||||||
<span>{{ info.value }}</span>
|
|
||||||
</p>
|
|
||||||
<NuxtLink
|
|
||||||
v-if="selectedProduct.id"
|
|
||||||
:to="`/product/${selectedProduct.id}`"
|
|
||||||
class="link link-primary text-xs"
|
|
||||||
>
|
|
||||||
Ouvrir la fiche produit
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-xs text-base-content/60">
|
|
||||||
Aucun produit associé.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-2">
|
</div>
|
||||||
<div v-if="displayProduct" class="space-y-1">
|
|
||||||
<p class="font-medium text-base-content">
|
<!-- ── Section: Produit catalogue ── -->
|
||||||
{{ displayProductName || 'Produit catalogue' }}
|
<div v-if="isEditMode || displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
</p>
|
<div class="px-4 py-2 bg-info/10 border-b border-info/20">
|
||||||
<p
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
|
||||||
v-for="info in productInfoRows"
|
</div>
|
||||||
:key="info.label"
|
<div class="p-4">
|
||||||
class="text-xs text-base-content/70"
|
<!-- Edit mode -->
|
||||||
|
<div v-if="isEditMode" class="space-y-3">
|
||||||
|
<ProductSelect
|
||||||
|
:model-value="pieceData.productId"
|
||||||
|
placeholder="Associer un produit…"
|
||||||
|
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
|
||||||
|
@update:modelValue="handleProductChange"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="selectedProduct"
|
||||||
|
class="rounded-lg border border-base-200/60 bg-base-200/20 p-3 space-y-1.5"
|
||||||
>
|
>
|
||||||
<span class="font-semibold">{{ info.label }} :</span>
|
<p class="text-sm font-bold text-base-content">{{ selectedProduct.name }}</p>
|
||||||
<span class="ml-1">{{ info.value }}</span>
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
</p>
|
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
|
||||||
|
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NuxtLink v-if="selectedProduct.id" :to="`/product/${selectedProduct.id}`" class="link link-primary text-xs">
|
||||||
|
Ouvrir la fiche produit
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Read-only mode -->
|
||||||
|
<div v-else-if="displayProduct">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
|
||||||
|
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="piece.product?.id || piece.productId"
|
||||||
|
:to="`/product/${piece.product?.id || piece.productId}`"
|
||||||
|
class="btn btn-ghost btn-xs shrink-0 gap-1"
|
||||||
|
>
|
||||||
|
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
|
||||||
|
Voir
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
<ProductDocumentsInline
|
<ProductDocumentsInline
|
||||||
|
v-if="productDocuments.length"
|
||||||
|
class="mt-3 pt-3 border-t border-base-200/50"
|
||||||
:documents="productDocuments"
|
:documents="productDocuments"
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="font-medium">
|
|
||||||
Non défini
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- ── Section: Champs personnalisés item ── -->
|
||||||
|
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<CustomFieldDisplay
|
||||||
|
:fields="displayedCustomFields"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:show-header="false"
|
||||||
|
:with-top-border="false"
|
||||||
|
:editable="false"
|
||||||
|
@field-input="handleCustomFieldInput"
|
||||||
|
@field-blur="handleCustomFieldBlur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Champs personnalisés de la pièce -->
|
<!-- ── Section: Champs personnalisés machine ── -->
|
||||||
<CustomFieldDisplay
|
<div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
:fields="displayedCustomFields"
|
<div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
|
||||||
:is-edit-mode="isEditMode"
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
|
||||||
title="Champs personnalisés item"
|
</div>
|
||||||
:editable="false"
|
<div class="p-4">
|
||||||
@field-input="handleCustomFieldInput"
|
<CustomFieldDisplay
|
||||||
@field-blur="handleCustomFieldBlur"
|
:fields="mergedContextFields"
|
||||||
/>
|
:is-edit-mode="isEditMode"
|
||||||
|
:columns="2"
|
||||||
<template v-if="mergedContextFields.length">
|
:show-header="false"
|
||||||
<div class="divider my-4 text-xs text-base-content/50">
|
:with-top-border="false"
|
||||||
Champs personnalisés machine
|
:editable="true"
|
||||||
</div>
|
:emit-blur="false"
|
||||||
<CustomFieldDisplay
|
@field-input="queueContextCustomFieldUpdate"
|
||||||
:fields="mergedContextFields"
|
/>
|
||||||
:is-edit-mode="isEditMode"
|
</div>
|
||||||
:columns="2"
|
|
||||||
:show-header="false"
|
|
||||||
:with-top-border="false"
|
|
||||||
:editable="true"
|
|
||||||
:emit-blur="false"
|
|
||||||
@field-input="queueContextCustomFieldUpdate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
|
|
||||||
<span
|
|
||||||
v-if="isEditMode && selectedFiles.length"
|
|
||||||
class="badge badge-outline"
|
|
||||||
>
|
|
||||||
{{ selectedFiles.length }} fichier{{
|
|
||||||
selectedFiles.length > 1 ? "s" : ""
|
|
||||||
}}
|
|
||||||
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
<!-- ── Section: Documents ── -->
|
||||||
Chargement des documents...
|
<div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
|
||||||
</p>
|
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
|
||||||
|
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
|
||||||
|
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
||||||
|
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement des documents...</p>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-if="isEditMode"
|
v-if="isEditMode"
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer des fichiers pour cette pièce"
|
title="Déposer des fichiers pour cette pièce"
|
||||||
subtitle="Formats acceptés : PDF, images, documents..."
|
subtitle="Formats acceptés : PDF, images, documents..."
|
||||||
@files-added="handleFilesAdded"
|
@files-added="handleFilesAdded"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentListInline
|
<DocumentListInline
|
||||||
:documents="pieceDocuments"
|
:documents="pieceDocuments"
|
||||||
:can-delete="isEditMode"
|
:can-delete="isEditMode"
|
||||||
:can-edit="isEditMode"
|
:can-edit="isEditMode"
|
||||||
:delete-disabled="uploadingDocuments"
|
:delete-disabled="uploadingDocuments"
|
||||||
empty-text="Aucun document lié à cette pièce."
|
empty-text="Aucun document lié à cette pièce."
|
||||||
@preview="openPreview"
|
@preview="openPreview"
|
||||||
@edit="openEditModal"
|
@edit="openEditModal"
|
||||||
@delete="removeDocument"
|
@delete="removeDocument"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -321,6 +356,8 @@ import ProductSelect from '~/components/ProductSelect.vue'
|
|||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
import IconLucideExternalLink from '~icons/lucide/external-link'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import {
|
import {
|
||||||
@@ -468,6 +505,24 @@ const mergedContextFields = computed(() => {
|
|||||||
return mergeDefinitionsWithValues(definitions, values)
|
return mergeDefinitionsWithValues(definitions, values)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Context fields shown as tags on the header (consultation mode)
|
||||||
|
const visibleContextFieldTags = computed(() =>
|
||||||
|
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CONTEXT_FIELD_COLORS = [
|
||||||
|
'bg-secondary/25 text-secondary border border-secondary/35',
|
||||||
|
'bg-accent/25 text-accent border border-accent/35',
|
||||||
|
'bg-info/25 text-info border border-info/35',
|
||||||
|
'bg-success/25 text-success border border-success/35',
|
||||||
|
'bg-warning/25 text-warning border border-warning/35',
|
||||||
|
]
|
||||||
|
|
||||||
|
const contextFieldBadgeClass = (field) => {
|
||||||
|
const idx = visibleContextFieldTags.value.indexOf(field)
|
||||||
|
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
const queueContextCustomFieldUpdate = (field, value) => {
|
const queueContextCustomFieldUpdate = (field, value) => {
|
||||||
const linkId = props.piece?.linkId
|
const linkId = props.piece?.linkId
|
||||||
if (!linkId || !field) return
|
if (!linkId || !field) return
|
||||||
@@ -689,12 +744,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
pieceData.name = props.piece.name || ''
|
|
||||||
pieceData.reference = props.piece.reference || ''
|
|
||||||
pieceData.prix = props.piece.prix || ''
|
|
||||||
pieceData.quantity = props.piece.quantity ?? 1
|
|
||||||
loadProducts().catch(() => {})
|
loadProducts().catch(() => {})
|
||||||
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
|
|
||||||
if (!props.piece.documents?.length) refreshDocuments()
|
if (!props.piece.documents?.length) refreshDocuments()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="space-y-3">
|
<section v-if="!hideProducts" class="space-y-3">
|
||||||
<header>
|
<header>
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
Produits inclus par défaut
|
Produits inclus par défaut
|
||||||
@@ -94,12 +94,11 @@
|
|||||||
|
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<input
|
<CustomFieldNameInput
|
||||||
v-model="field.name"
|
v-model="field.name"
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-xs"
|
|
||||||
placeholder="Nom du champ"
|
placeholder="Nom du champ"
|
||||||
>
|
size="xs"
|
||||||
|
/>
|
||||||
<select v-model="field.type" class="select select-bordered select-xs">
|
<select v-model="field.type" class="select select-bordered select-xs">
|
||||||
<option value="text">
|
<option value="text">
|
||||||
Texte
|
Texte
|
||||||
@@ -166,6 +165,7 @@ defineOptions({ name: 'PieceModelStructureEditor' })
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: PieceModelStructure | null
|
modelValue?: PieceModelStructure | null
|
||||||
|
hideProducts?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -103,11 +103,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<input
|
<CustomFieldNameInput
|
||||||
v-model="field.name"
|
v-model="field.name"
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-xs"
|
|
||||||
placeholder="Nom du champ"
|
placeholder="Nom du champ"
|
||||||
|
size="xs"
|
||||||
/>
|
/>
|
||||||
<select v-model="field.type" class="select select-bordered select-xs">
|
<select v-model="field.type" class="select select-bordered select-xs">
|
||||||
<option value="text">Texte</option>
|
<option value="text">Texte</option>
|
||||||
|
|||||||
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>
|
||||||
@@ -77,6 +77,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,6 +96,7 @@
|
|||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||||
import IconLucideX from '~icons/lucide/x'
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -137,10 +147,14 @@ const props = defineProps({
|
|||||||
serverSearch: {
|
serverSearch: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
creatable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'search'])
|
const emit = defineEmits(['update:modelValue', 'search', 'focus'])
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const openDropdown = ref(false)
|
const openDropdown = ref(false)
|
||||||
@@ -172,6 +186,18 @@ const displayedOptions = computed(() => {
|
|||||||
return filtered
|
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 inputClasses = computed(() => {
|
||||||
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
||||||
const base = ['input', 'input-bordered', 'w-full', pr]
|
const base = ['input', 'input-bordered', 'w-full', pr]
|
||||||
@@ -194,6 +220,12 @@ const toggleButtonClasses = computed(() => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
() => {
|
() => {
|
||||||
|
if (props.creatable) {
|
||||||
|
if (searchTerm.value !== props.modelValue) {
|
||||||
|
searchTerm.value = String(props.modelValue ?? '')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!openDropdown.value) {
|
if (!openDropdown.value) {
|
||||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||||
}
|
}
|
||||||
@@ -269,6 +301,7 @@ function handleFocus () {
|
|||||||
if (searchTerm.value === '' && selectedOption.value) {
|
if (searchTerm.value === '' && selectedOption.value) {
|
||||||
searchTerm.value = resolveLabel(selectedOption.value)
|
searchTerm.value = resolveLabel(selectedOption.value)
|
||||||
}
|
}
|
||||||
|
emit('focus')
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDropdown () {
|
function toggleDropdown () {
|
||||||
@@ -285,6 +318,9 @@ function handleInput () {
|
|||||||
if (!openDropdown.value) {
|
if (!openDropdown.value) {
|
||||||
openDropdown.value = true
|
openDropdown.value = true
|
||||||
}
|
}
|
||||||
|
if (props.creatable) {
|
||||||
|
emit('update:modelValue', searchTerm.value)
|
||||||
|
}
|
||||||
emit('search', searchTerm.value)
|
emit('search', searchTerm.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,8 +330,18 @@ function clearSelection () {
|
|||||||
openDropdown.value = false
|
openDropdown.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmCreatable () {
|
||||||
|
if (creatableSuggestion.value) {
|
||||||
|
emit('update:modelValue', creatableSuggestion.value)
|
||||||
|
}
|
||||||
|
openDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function closeDropdown () {
|
function closeDropdown () {
|
||||||
openDropdown.value = false
|
openDropdown.value = false
|
||||||
|
if (props.creatable) {
|
||||||
|
return // keep the typed text as-is
|
||||||
|
}
|
||||||
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||||
emit('update:modelValue', '')
|
emit('update:modelValue', '')
|
||||||
} else if (selectedOption.value) {
|
} else if (selectedOption.value) {
|
||||||
@@ -342,7 +388,11 @@ const handleGlobalClick = (event) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('click', handleGlobalClick)
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
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>
|
||||||
@@ -33,12 +33,11 @@
|
|||||||
|
|
||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<input
|
<CustomFieldNameInput
|
||||||
v-model="field.name"
|
v-model="field.name"
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
placeholder="Nom du champ"
|
placeholder="Nom du champ"
|
||||||
>
|
size="sm"
|
||||||
|
/>
|
||||||
<select v-model="field.type" class="select select-bordered select-sm">
|
<select v-model="field.type" class="select select-bordered select-sm">
|
||||||
<option value="text">
|
<option value="text">
|
||||||
Texte
|
Texte
|
||||||
|
|||||||
@@ -50,12 +50,11 @@
|
|||||||
<div class="flex-1 space-y-2">
|
<div class="flex-1 space-y-2">
|
||||||
<!-- Definition fields -->
|
<!-- Definition fields -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||||
<input
|
<CustomFieldNameInput
|
||||||
:value="field.name"
|
:model-value="field.name"
|
||||||
type="text"
|
|
||||||
class="input input-bordered input-sm"
|
|
||||||
placeholder="Nom du champ"
|
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
|
<select
|
||||||
:value="field.type || 'text'"
|
:value="field.type || 'text'"
|
||||||
|
|||||||
@@ -57,16 +57,6 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="canEdit"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="openCreatePage"
|
|
||||||
>
|
|
||||||
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
|
|
||||||
Créer
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-name="{ row }">
|
<template #cell-name="{ row }">
|
||||||
@@ -78,19 +68,15 @@
|
|||||||
<span v-else class="text-base-content/50">—</span>
|
<span v-else class="text-base-content/50">—</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-createdAt="{ row }">
|
||||||
|
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="canEdit && showConvertButton"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs text-warning"
|
|
||||||
@click="openConversionModal(row)"
|
|
||||||
>
|
|
||||||
Convertir
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
@@ -101,13 +87,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<ConversionModal
|
|
||||||
:open="conversionModalOpen"
|
|
||||||
:model-type="conversionTarget"
|
|
||||||
@close="closeConversionModal"
|
|
||||||
@converted="onConverted"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RelatedItemsModal
|
<RelatedItemsModal
|
||||||
:open="relatedModalOpen"
|
:open="relatedModalOpen"
|
||||||
:model-type="relatedType"
|
:model-type="relatedType"
|
||||||
@@ -121,7 +100,6 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||||
import { useHead, useRouter } from '#imports'
|
import { useHead, useRouter } from '#imports'
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import ConversionModal from '~/components/model-types/ConversionModal.vue'
|
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import type { DataTableSort } from '~/shared/types/dataTable'
|
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||||
import {
|
import {
|
||||||
@@ -135,7 +113,7 @@ import { useToast } from '~/composables/useToast'
|
|||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import IconLucideSearch from '~icons/lucide/search'
|
import IconLucideSearch from '~icons/lucide/search'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
|
|
||||||
const DEFAULT_DESCRIPTION
|
const DEFAULT_DESCRIPTION
|
||||||
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
||||||
@@ -199,12 +177,11 @@ useHead(() => ({ title: headingText.value }))
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: 'Nom', sortable: true },
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
{ key: 'notes', label: 'Notes' },
|
{ key: 'notes', label: 'Notes' },
|
||||||
|
{ key: 'createdAt', label: 'Date', sortable: true },
|
||||||
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const showConvertButton = computed(() =>
|
const formatDate = formatFrenchDate
|
||||||
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
|
|
||||||
)
|
|
||||||
|
|
||||||
const categories: Array<{ label: string, value: ModelCategory }> = [
|
const categories: Array<{ label: string, value: ModelCategory }> = [
|
||||||
{ label: 'Composants', value: 'COMPONENT' },
|
{ label: 'Composants', value: 'COMPONENT' },
|
||||||
@@ -339,13 +316,6 @@ const resolveCategoryBasePath = (category: ModelCategory) => {
|
|||||||
return '/product-category'
|
return '/product-category'
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreatePage = () => {
|
|
||||||
const basePath = resolveCategoryBasePath(selectedCategory.value)
|
|
||||||
router.push(`${basePath}/new`).catch(() => {
|
|
||||||
showError('Navigation impossible vers la page de création.')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditPage = (item: ModelType) => {
|
const openEditPage = (item: ModelType) => {
|
||||||
const category = item.category ?? selectedCategory.value
|
const category = item.category ?? selectedCategory.value
|
||||||
const basePath = resolveCategoryBasePath(category)
|
const basePath = resolveCategoryBasePath(category)
|
||||||
@@ -400,26 +370,6 @@ const openRelatedEdit = (entry: { id: string }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversionModalOpen = ref(false)
|
|
||||||
const conversionTarget = ref<ModelType | null>(null)
|
|
||||||
|
|
||||||
const openConversionModal = (item: ModelType) => {
|
|
||||||
conversionTarget.value = item
|
|
||||||
conversionModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeConversionModal = () => {
|
|
||||||
conversionModalOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onConverted = () => {
|
|
||||||
conversionModalOpen.value = false
|
|
||||||
invalidateEntityTypeCache('PIECE')
|
|
||||||
invalidateEntityTypeCache('COMPONENT')
|
|
||||||
showSuccess('Catégorie convertie avec succès.')
|
|
||||||
doRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
@@ -99,11 +99,7 @@
|
|||||||
v-else
|
v-else
|
||||||
class="space-y-3 rounded-lg border border-base-300 p-4"
|
class="space-y-3 rounded-lg border border-base-300 p-4"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-base-content/70">
|
<PieceModelStructureEditor v-model="productStructure" hide-products />
|
||||||
Aperçu :
|
|
||||||
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
|
||||||
</p>
|
|
||||||
<PieceModelStructureEditor v-model="productStructure" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@@ -194,20 +190,21 @@ const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const formulaBuilderCustomFields = computed(() => {
|
const formulaBuilderCustomFields = computed(() => {
|
||||||
|
let fields: any[] = []
|
||||||
if (form.category === 'PIECE') {
|
if (form.category === 'PIECE') {
|
||||||
const fields = pieceStructure.value?.customFields
|
const raw = pieceStructure.value?.customFields
|
||||||
return Array.isArray(fields) ? fields : []
|
fields = Array.isArray(raw) ? raw : []
|
||||||
}
|
}
|
||||||
if (form.category === 'COMPONENT') {
|
else if (form.category === 'COMPONENT') {
|
||||||
const fields = componentStructure.value?.customFields
|
const raw = componentStructure.value?.customFields
|
||||||
return Array.isArray(fields) ? fields : []
|
fields = Array.isArray(raw) ? raw : []
|
||||||
}
|
}
|
||||||
return []
|
return fields.filter((f: any) => !f.machineContextOnly)
|
||||||
})
|
})
|
||||||
|
|
||||||
const extractFormulaFields = (formula: string | null | undefined): string[] => {
|
const extractFormulaFields = (formula: string | null | undefined): string[] => {
|
||||||
if (!formula) return []
|
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))]
|
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')
|
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) => {
|
const insertField = (fieldName: string) => {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
hasAssignments,
|
hasAssignments,
|
||||||
initializeStructureAssignments,
|
initializeStructureAssignments,
|
||||||
isAssignmentNodeComplete,
|
|
||||||
serializeStructureAssignments,
|
serializeStructureAssignments,
|
||||||
} from '~/shared/utils/structureAssignmentHelpers'
|
} from '~/shared/utils/structureAssignmentHelpers'
|
||||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
@@ -152,24 +151,14 @@ export function useComponentCreate() {
|
|||||||
values: computed(() => []),
|
values: computed(() => []),
|
||||||
entityType: 'composant',
|
entityType: 'composant',
|
||||||
entityId: createdComponentId,
|
entityId: createdComponentId,
|
||||||
|
context: 'standalone',
|
||||||
})
|
})
|
||||||
|
|
||||||
const structureHasRequirements = computed(() =>
|
const structureHasRequirements = computed(() =>
|
||||||
hasAssignments(structureAssignments.value),
|
hasAssignments(structureAssignments.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
const structureSelectionsComplete = computed(() => {
|
const structureSelectionsComplete = computed(() => true)
|
||||||
if (!structureHasRequirements.value) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (structureDataLoading.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!structureAssignments.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return isAssignmentNodeComplete(structureAssignments.value, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
canEdit.value
|
canEdit.value
|
||||||
@@ -307,11 +296,6 @@ export function useComponentCreate() {
|
|||||||
payload.productId = rootProductSelection.selectedProductId.trim()
|
payload.productId = rootProductSelection.selectedProductId.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
|
|
||||||
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializedStructure = structureHasRequirements.value
|
const serializedStructure = structureHasRequirements.value
|
||||||
? serializeStructureAssignments(structureAssignments.value)
|
? serializeStructureAssignments(structureAssignments.value)
|
||||||
: null
|
: null
|
||||||
@@ -414,6 +398,7 @@ export function useComponentCreate() {
|
|||||||
structureSelectionsComplete,
|
structureSelectionsComplete,
|
||||||
canEdit,
|
canEdit,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
typeOptionLabel,
|
typeOptionLabel,
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export function useComponentEdit(componentId: string) {
|
|||||||
values: computed(() => component.value?.customFieldValues ?? []),
|
values: computed(() => component.value?.customFieldValues ?? []),
|
||||||
entityType: 'composant',
|
entityType: 'composant',
|
||||||
entityId: computed(() => component.value?.id ?? null),
|
entityId: computed(() => component.value?.id ?? null),
|
||||||
|
context: 'standalone',
|
||||||
onValueCreated: (newValue) => {
|
onValueCreated: (newValue) => {
|
||||||
if (component.value && Array.isArray(component.value.customFieldValues)) {
|
if (component.value && Array.isArray(component.value.customFieldValues)) {
|
||||||
component.value.customFieldValues.push(newValue)
|
component.value.customFieldValues.push(newValue)
|
||||||
@@ -556,6 +557,7 @@ export function useComponentEdit(componentId: string) {
|
|||||||
originalConstructeurLinks,
|
originalConstructeurLinks,
|
||||||
constructeurIdsFromForm,
|
constructeurIdsFromForm,
|
||||||
customFieldInputs,
|
customFieldInputs,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
historyFieldLabels,
|
historyFieldLabels,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
|
|||||||
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 { ref } from 'vue'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
|
export interface ConstructeurTelephone {
|
||||||
|
'@id'?: string
|
||||||
|
id?: string
|
||||||
|
numero: string
|
||||||
|
label?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstructeurCategorieRef {
|
||||||
|
'@id'?: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Constructeur {
|
export interface Constructeur {
|
||||||
|
'@id'?: string
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
email?: string | null
|
email?: string | null
|
||||||
phone?: string | null
|
telephones?: ConstructeurTelephone[]
|
||||||
|
categories?: ConstructeurCategorieRef[]
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConstructeurResult {
|
interface ConstructeurResult {
|
||||||
@@ -16,6 +33,24 @@ interface ConstructeurResult {
|
|||||||
error?: string
|
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 constructeurs = ref<Constructeur[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
@@ -66,8 +101,10 @@ export function useConstructeurs() {
|
|||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
const params = new URLSearchParams()
|
||||||
const result = await get(`/constructeurs${query}`)
|
params.set('itemsPerPage', '2000')
|
||||||
|
if (search) params.set('search', search)
|
||||||
|
const result = await get(`/constructeurs?${params.toString()}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const items = extractCollection(result.data)
|
const items = extractCollection(result.data)
|
||||||
constructeurs.value = uniqueConstructeurs(items)
|
constructeurs.value = uniqueConstructeurs(items)
|
||||||
@@ -87,7 +124,38 @@ export function useConstructeurs() {
|
|||||||
return loadConstructeurs(search)
|
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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await post('/constructeurs', data)
|
const result = await post('/constructeurs', data)
|
||||||
@@ -161,7 +229,7 @@ export function useConstructeurs() {
|
|||||||
.filter((item): item is Constructeur => item !== null)
|
.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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await patch(`/constructeurs/${id}`, data)
|
const result = await patch(`/constructeurs/${id}`, data)
|
||||||
@@ -210,6 +278,7 @@ export function useConstructeurs() {
|
|||||||
loading,
|
loading,
|
||||||
loadConstructeurs,
|
loadConstructeurs,
|
||||||
searchConstructeurs,
|
searchConstructeurs,
|
||||||
|
fetchConstructeursPage,
|
||||||
createConstructeur,
|
createConstructeur,
|
||||||
updateConstructeur,
|
updateConstructeur,
|
||||||
deleteConstructeur,
|
deleteConstructeur,
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions'
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import {
|
import {
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
@@ -79,6 +80,7 @@ export function invalidateEntityTypeCache(category: ModelCategory) {
|
|||||||
export function useEntityTypes(config: EntityTypeConfig) {
|
export function useEntityTypes(config: EntityTypeConfig) {
|
||||||
const { category, label } = config
|
const { category, label } = config
|
||||||
const { showSuccess, showError } = useToast()
|
const { showSuccess, showError } = useToast()
|
||||||
|
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
|
||||||
const state = getOrCreateState(category)
|
const state = getOrCreateState(category)
|
||||||
|
|
||||||
const normalizeItem = (item: ModelType): EntityType => ({
|
const normalizeItem = (item: ModelType): EntityType => ({
|
||||||
@@ -124,6 +126,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
})
|
})
|
||||||
const normalized = normalizeItem(data)
|
const normalized = normalizeItem(data)
|
||||||
state.types.value.push(normalized)
|
state.types.value.push(normalized)
|
||||||
|
invalidateCustomFieldNames()
|
||||||
showSuccess(`Type de ${label} "${data.name}" créé`)
|
showSuccess(`Type de ${label} "${data.name}" créé`)
|
||||||
return { success: true, data: normalized }
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -150,6 +153,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
const normalized = normalizeItem(data)
|
const normalized = normalizeItem(data)
|
||||||
const index = state.types.value.findIndex((t) => t.id === id)
|
const index = state.types.value.findIndex((t) => t.id === id)
|
||||||
if (index !== -1) state.types.value[index] = normalized
|
if (index !== -1) state.types.value[index] = normalized
|
||||||
|
invalidateCustomFieldNames()
|
||||||
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
|
||||||
return { success: true, data: normalized }
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
@@ -88,6 +89,7 @@ const parseOptions = (optionsText: string): string[] =>
|
|||||||
export function useMachineCustomFieldDefs(deps: Deps) {
|
export function useMachineCustomFieldDefs(deps: Deps) {
|
||||||
const { apiCall } = useApi()
|
const { apiCall } = useApi()
|
||||||
const { showSuccess, showError } = useToast()
|
const { showSuccess, showError } = useToast()
|
||||||
|
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
|
|
||||||
@@ -294,6 +296,7 @@ export function useMachineCustomFieldDefs(deps: Deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showSuccess('Champs personnalisés sauvegardés avec succès')
|
showSuccess('Champs personnalisés sauvegardés avec succès')
|
||||||
|
invalidateCustomFieldNames()
|
||||||
await deps.onSaved()
|
await deps.onSaved()
|
||||||
} catch {
|
} catch {
|
||||||
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')
|
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
if (!machineName.value.trim()) return false
|
if (!machineName.value.trim()) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
const debug = ref(false)
|
|
||||||
|
|
||||||
const componentsCollapsed = ref(true)
|
const componentsCollapsed = ref(true)
|
||||||
const collapseToggleToken = ref(0)
|
const collapseToggleToken = ref(0)
|
||||||
@@ -227,22 +226,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
const componentTypeOptions = computed(() => componentTypes.value || [])
|
const componentTypeOptions = computed(() => componentTypes.value || [])
|
||||||
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
const pieceTypeOptions = computed(() => pieceTypes.value || [])
|
||||||
|
|
||||||
const componentTypeLabelMap = computed(() => {
|
|
||||||
const map = new Map<string, string>()
|
|
||||||
componentTypeOptions.value.forEach((type) => {
|
|
||||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieceTypeLabelMap = computed(() => {
|
|
||||||
const map = new Map<string, string>()
|
|
||||||
pieceTypeOptions.value.forEach((type) => {
|
|
||||||
if (type?.id) map.set(type.id as string, (type.name as string) || '')
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
// Machine field methods
|
// Machine field methods
|
||||||
const initMachineFields = () => {
|
const initMachineFields = () => {
|
||||||
if (machine.value) {
|
if (machine.value) {
|
||||||
@@ -306,7 +289,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
// UI methods
|
// UI methods
|
||||||
const toggleEditMode = () => {
|
const toggleEditMode = () => {
|
||||||
isEditMode.value = !isEditMode.value
|
isEditMode.value = !isEditMode.value
|
||||||
debug.value = !debug.value
|
|
||||||
if (isEditMode.value && !machineDocumentsLoaded.value) {
|
if (isEditMode.value && !machineDocumentsLoaded.value) {
|
||||||
refreshMachineDocuments()
|
refreshMachineDocuments()
|
||||||
}
|
}
|
||||||
@@ -432,12 +414,6 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
await productsPromise
|
await productsPromise
|
||||||
const linksApplied = applyMachineLinks(machineResult.data)
|
const linksApplied = applyMachineLinks(machineResult.data)
|
||||||
|
|
||||||
if (machine.value) {
|
|
||||||
machine.value.componentLinks = machineComponentLinks.value
|
|
||||||
machine.value.pieceLinks = machinePieceLinks.value
|
|
||||||
machine.value.productLinks = machineProductLinks.value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!linksApplied) {
|
if (!linksApplied) {
|
||||||
components.value = transformComponentCustomFields(machinePayload.components || [])
|
components.value = transformComponentCustomFields(machinePayload.components || [])
|
||||||
pieces.value = transformCustomFields(machinePayload.pieces || [])
|
pieces.value = transformCustomFields(machinePayload.pieces || [])
|
||||||
@@ -447,6 +423,8 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (machine.value) {
|
if (machine.value) {
|
||||||
|
machine.value.componentLinks = machineComponentLinks.value
|
||||||
|
machine.value.pieceLinks = machinePieceLinks.value
|
||||||
machine.value.productLinks = machineProductLinks.value
|
machine.value.productLinks = machineProductLinks.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,11 +474,11 @@ export function useMachineDetailData(machineId: string) {
|
|||||||
// UI state
|
// UI state
|
||||||
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
||||||
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
||||||
isEditMode, debug,
|
isEditMode,
|
||||||
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap,
|
componentTypeOptions, pieceTypeOptions,
|
||||||
productInventory, productById, flattenedComponents, machinePieces,
|
productInventory, productById, flattenedComponents, machinePieces,
|
||||||
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
buildProductRequirementDescriptions,
|
buildProductRequirementDescriptions,
|
||||||
buildProductRequirementEntries,
|
buildProductRequirementEntries,
|
||||||
resizeProductSelections,
|
resizeProductSelections,
|
||||||
areProductSelectionsFilled,
|
|
||||||
applyProductSelection,
|
applyProductSelection,
|
||||||
collectNormalizedProductIds,
|
collectNormalizedProductIds,
|
||||||
} from '~/shared/utils/pieceProductSelectionUtils'
|
} from '~/shared/utils/pieceProductSelectionUtils'
|
||||||
@@ -99,6 +98,7 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
values: computed(() => piece.value?.customFieldValues ?? []),
|
values: computed(() => piece.value?.customFieldValues ?? []),
|
||||||
entityType: 'piece',
|
entityType: 'piece',
|
||||||
entityId: computed(() => piece.value?.id ?? null),
|
entityId: computed(() => piece.value?.id ?? null),
|
||||||
|
context: 'standalone',
|
||||||
onValueCreated: (newValue) => {
|
onValueCreated: (newValue) => {
|
||||||
if (piece.value && Array.isArray(piece.value.customFieldValues)) {
|
if (piece.value && Array.isArray(piece.value.customFieldValues)) {
|
||||||
piece.value.customFieldValues.push(newValue)
|
piece.value.customFieldValues.push(newValue)
|
||||||
@@ -198,13 +198,7 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
|
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const productSelectionsFilled = computed(() =>
|
const productSelectionsFilled = computed(() => true)
|
||||||
areProductSelectionsFilled(
|
|
||||||
requiresProductSelection.value,
|
|
||||||
productRequirementEntries.value,
|
|
||||||
productSelections.value,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const setProductSelection = (index: number, value: string | null) => {
|
const setProductSelection = (index: number, value: string | null) => {
|
||||||
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
||||||
@@ -354,11 +348,6 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!productSelectionsFilled.value) {
|
|
||||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPrice = typeof editionForm.prix === 'string'
|
const rawPrice = typeof editionForm.prix === 'string'
|
||||||
? editionForm.prix.trim()
|
? editionForm.prix.trim()
|
||||||
: editionForm.prix === null || editionForm.prix === undefined
|
: editionForm.prix === null || editionForm.prix === undefined
|
||||||
@@ -435,6 +424,7 @@ export function usePieceEdit(pieceId: string) {
|
|||||||
constructeurIdsFromForm,
|
constructeurIdsFromForm,
|
||||||
productSelections,
|
productSelections,
|
||||||
customFieldInputs,
|
customFieldInputs,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function useToast() {
|
|||||||
message,
|
message,
|
||||||
type,
|
type,
|
||||||
visible: true,
|
visible: true,
|
||||||
duration: type === 'error' ? 0 : duration,
|
duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toasts.value.length >= MAX_TOASTS) {
|
if (toasts.value.length >= MAX_TOASTS) {
|
||||||
@@ -42,8 +42,7 @@ export function useToast() {
|
|||||||
|
|
||||||
toasts.value.push(toast)
|
toasts.value.push(toast)
|
||||||
|
|
||||||
// Only auto-dismiss non-error toasts
|
if (duration > 0) {
|
||||||
if (type !== 'error' && duration > 0) {
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
removeToast(id)
|
removeToast(id)
|
||||||
}, duration)
|
}, duration)
|
||||||
@@ -56,8 +55,8 @@ export function useToast() {
|
|||||||
return showToast(message, 'success', duration)
|
return showToast(message, 'success', duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showError = (message: string): number => {
|
const showError = (message: string, duration = 8000): number => {
|
||||||
return showToast(message, 'error', 0)
|
return showToast(message, 'error', duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
const showWarning = (message: string, duration = 6000): number => {
|
const showWarning = (message: string, duration = 6000): number => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
<option value="piece">Pièce</option>
|
<option value="piece">Pièce</option>
|
||||||
<option value="product">Produit</option>
|
<option value="product">Produit</option>
|
||||||
<option value="composant">Composant</option>
|
<option value="composant">Composant</option>
|
||||||
|
<option value="machine">Machine</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,13 +90,16 @@
|
|||||||
|
|
||||||
<template #cell-entity="{ row }">
|
<template #cell-entity="{ row }">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="row.action !== 'delete'"
|
v-if="row.action !== 'delete' && entityEditLink(row) !== '#'"
|
||||||
:to="entityEditLink(row)"
|
:to="entityEditLink(row)"
|
||||||
class="link link-hover link-primary"
|
class="link link-hover link-primary"
|
||||||
>
|
>
|
||||||
{{ row.entityName || 'Sans nom' }}
|
{{ row.entityName || 'Sans nom' }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span v-else class="text-base-content/50 line-through">
|
<span v-else-if="row.action === 'delete'" class="text-base-content/50 line-through">
|
||||||
|
{{ row.entityName || 'Sans nom' }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
{{ row.entityName || 'Sans nom' }}
|
{{ row.entityName || 'Sans nom' }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -195,19 +199,23 @@ const ENTITY_TYPE_LABELS: Record<string, string> = {
|
|||||||
piece: 'Pièce',
|
piece: 'Pièce',
|
||||||
product: 'Produit',
|
product: 'Produit',
|
||||||
composant: 'Composant',
|
composant: 'Composant',
|
||||||
|
machine: 'Machine',
|
||||||
|
document: 'Document',
|
||||||
|
model_type: 'Modèle',
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
|
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
|
||||||
|
|
||||||
const ENTITY_EDIT_ROUTES: Record<string, string> = {
|
const ENTITY_ROUTES: Record<string, string> = {
|
||||||
piece: '/pieces',
|
piece: '/piece',
|
||||||
product: '/product',
|
product: '/product',
|
||||||
composant: '/component',
|
composant: '/component',
|
||||||
|
machine: '/machine',
|
||||||
}
|
}
|
||||||
|
|
||||||
const entityEditLink = (entry: ActivityLogEntry) => {
|
const entityEditLink = (entry: ActivityLogEntry) => {
|
||||||
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
|
const base = ENTITY_ROUTES[entry.entityType] ?? ''
|
||||||
return base ? `${base}/${entry.entityId}/edit` : '#'
|
return base ? `${base}/${entry.entityId}` : '#'
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionBadgeClass = (action: string) => {
|
const actionBadgeClass = (action: string) => {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<h1 class="text-3xl font-bold tracking-tight">Composants</h1>
|
<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>
|
<p class="text-sm text-base-content/70">Catalogue et catégories de composants.</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink v-if="canEdit" to="/component/create" class="btn btn-primary btn-sm md:btn-md">
|
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/component-category/new' : '/component/create'" class="btn btn-primary btn-sm md:btn-md">
|
||||||
Ajouter un composant
|
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter un composant' }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<h1 class="text-3xl font-bold tracking-tight">Pièces</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Pièces</h1>
|
||||||
<p class="text-sm text-base-content/70">Catalogue et catégories de pièces.</p>
|
<p class="text-sm text-base-content/70">Catalogue et catégories de pièces.</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink v-if="canEdit" to="/pieces/create" class="btn btn-primary btn-sm md:btn-md">
|
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/piece-category/new' : '/pieces/create'" class="btn btn-primary btn-sm md:btn-md">
|
||||||
Ajouter une pièce
|
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter une pièce' }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
<h1 class="text-3xl font-bold tracking-tight">Produits</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Produits</h1>
|
||||||
<p class="text-sm text-base-content/70">Catalogue et catégories de produits.</p>
|
<p class="text-sm text-base-content/70">Catalogue et catégories de produits.</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink v-if="canEdit" to="/product/create" class="btn btn-primary btn-sm md:btn-md">
|
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/product-category/new' : '/product/create'" class="btn btn-primary btn-sm md:btn-md">
|
||||||
Ajouter un produit
|
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter un produit' }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
|||||||
await updateModelType(id, enrichedPayload)
|
await updateModelType(id, enrichedPayload)
|
||||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||||
await loadComponentTypes({ force: true })
|
await loadComponentTypes({ force: true })
|
||||||
|
await loadCategory()
|
||||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -183,6 +184,7 @@ const handleSyncConfirm = async () => {
|
|||||||
confirmTypeChanges: !!hasModifications,
|
confirmTypeChanges: !!hasModifications,
|
||||||
})
|
})
|
||||||
await loadComponentTypes({ force: true })
|
await loadComponentTypes({ force: true })
|
||||||
|
await loadCategory()
|
||||||
showSuccess('Catégorie de composant mise à jour avec succès.')
|
showSuccess('Catégorie de composant mise à jour avec succès.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
|
|||||||
@@ -408,6 +408,9 @@
|
|||||||
</header>
|
</header>
|
||||||
<template v-if="isEditMode">
|
<template v-if="isEditMode">
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -468,6 +471,9 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -511,6 +517,7 @@ const {
|
|||||||
constructeurLinks,
|
constructeurLinks,
|
||||||
constructeurIdsFromForm,
|
constructeurIdsFromForm,
|
||||||
customFieldInputs,
|
customFieldInputs,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
historyFieldLabels,
|
historyFieldLabels,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
componentTypeList,
|
componentTypeList,
|
||||||
@@ -538,6 +545,8 @@ const {
|
|||||||
formatStructurePreview,
|
formatStructurePreview,
|
||||||
} = useComponentEdit(String(route.params.id))
|
} = useComponentEdit(String(route.params.id))
|
||||||
|
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
const submitEdition = async () => {
|
const submitEdition = async () => {
|
||||||
await _submitEdition()
|
await _submitEdition()
|
||||||
if (!saving.value) {
|
if (!saving.value) {
|
||||||
|
|||||||
@@ -223,6 +223,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else
|
v-else
|
||||||
@@ -242,6 +245,9 @@
|
|||||||
Créer le composant
|
Créer le composant
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires avant de créer le composant.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -290,8 +296,11 @@ const {
|
|||||||
resolveProductLabel,
|
resolveProductLabel,
|
||||||
resolveSubcomponentLabel,
|
resolveSubcomponentLabel,
|
||||||
submitCreation,
|
submitCreation,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
} = useComponentCreate()
|
} = useComponentCreate()
|
||||||
|
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
const entityTabs = computed(() => [
|
const entityTabs = computed(() => [
|
||||||
{ key: 'general', label: 'Général' },
|
{ key: 'general', label: 'Général' },
|
||||||
{ key: 'structure', label: 'Structure' },
|
{ key: 'structure', label: 'Structure' },
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
Fournisseurs
|
Fournisseurs
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
Gérez les fournisseurs et leurs coordonnées.
|
Gérez les fournisseurs, leurs coordonnées et leurs catégories.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
|
||||||
@@ -19,29 +19,69 @@
|
|||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:rows="filteredConstructeurs"
|
:rows="pageItems"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:sort="currentSort"
|
:sort="currentSort"
|
||||||
:show-counter="false"
|
:pagination="paginationState"
|
||||||
|
:show-counter="true"
|
||||||
|
:show-per-page="true"
|
||||||
empty-message="Aucun fournisseur trouvé."
|
empty-message="Aucun fournisseur trouvé."
|
||||||
no-results-message="Aucun fournisseur trouvé."
|
no-results-message="Aucun fournisseur trouvé."
|
||||||
@sort="handleSort"
|
@sort="handleSort"
|
||||||
|
@update:current-page="onPageChange"
|
||||||
|
@update:per-page="onPerPageChange"
|
||||||
>
|
>
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<label class="w-full sm:w-72">
|
<div class="flex flex-col sm:flex-row gap-3 w-full">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
<label class="w-full sm:w-72">
|
||||||
<input
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
v-model="searchTerm"
|
<input
|
||||||
type="search"
|
v-model="searchTerm"
|
||||||
class="input input-bordered input-sm w-full mt-1"
|
type="search"
|
||||||
placeholder="Nom, email ou téléphone"
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
@input="debouncedSearch"
|
placeholder="Nom, email ou téléphone"
|
||||||
/>
|
@input="debouncedSearch"
|
||||||
</label>
|
>
|
||||||
|
</label>
|
||||||
|
<label class="w-full sm:w-64">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Catégorie</span>
|
||||||
|
<select
|
||||||
|
v-model="selectedCategoryId"
|
||||||
|
class="select select-bordered select-sm w-full mt-1"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Toutes les catégories
|
||||||
|
</option>
|
||||||
|
<option v-for="cat in allCategories" :key="cat.id" :value="cat.id">
|
||||||
|
{{ cat.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-phone="{ row }">
|
<template #cell-telephones="{ row }">
|
||||||
{{ formatPhoneDisplay(row.phone) }}
|
<div v-if="rowPhones(row).length" class="flex flex-col gap-0.5">
|
||||||
|
<span v-for="(tel, idx) in rowPhones(row)" :key="idx" class="whitespace-nowrap text-sm">
|
||||||
|
{{ formatPhoneDisplay(tel.numero) }}
|
||||||
|
<span v-if="tel.label" class="text-xs text-base-content/50">({{ tel.label }})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-base-content/30">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-categories="{ row }">
|
||||||
|
<div v-if="row.categories && row.categories.length" class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="cat in row.categories"
|
||||||
|
:key="cat.id"
|
||||||
|
class="badge badge-ghost badge-sm cursor-pointer hover:badge-primary transition-colors"
|
||||||
|
@click="selectedCategoryId = cat.id"
|
||||||
|
>
|
||||||
|
{{ cat.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-base-content/30">—</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-createdAt="{ row }">
|
<template #cell-createdAt="{ row }">
|
||||||
@@ -96,7 +136,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
||||||
<div class="modal-box">
|
<div class="modal-box max-w-2xl">
|
||||||
<h3 class="font-bold text-lg mb-4">
|
<h3 class="font-bold text-lg mb-4">
|
||||||
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
|
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
|
||||||
</h3>
|
</h3>
|
||||||
@@ -105,10 +145,53 @@
|
|||||||
<label class="label"><span class="label-text">Nom</span></label>
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
|
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
|
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
|
||||||
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
|
|
||||||
|
<div class="form-control">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="label-text">Téléphones</span>
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="addTelephoneRow"
|
||||||
|
>
|
||||||
|
<IconLucidePlus class="w-3 h-3 mr-1" aria-hidden="true" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!form.telephones.length" class="text-sm text-base-content/50">
|
||||||
|
Aucun téléphone.
|
||||||
|
</p>
|
||||||
|
<div v-for="(tel, idx) in form.telephones" :key="idx" class="flex items-end gap-2 mb-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<FieldPhone v-model="tel.numero" label="" :disabled="!canEdit" placeholder="Ex: 05 49 00 00 00" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="tel.label"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-sm md:input-md w-40"
|
||||||
|
placeholder="Libellé (optionnel)"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-error"
|
||||||
|
aria-label="Supprimer ce téléphone"
|
||||||
|
@click="removeTelephoneRow(idx)"
|
||||||
|
>
|
||||||
|
<IconLucideX class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label"><span class="label-text">Catégories</span></label>
|
||||||
|
<ConstructeurCategorieSelect v-model="form.categories" :disabled="!canEdit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" @click="closeModal">
|
<button type="button" class="btn" @click="closeModal">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -125,26 +208,47 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||||
|
import ConstructeurCategorieSelect from '~/components/form/ConstructeurCategorieSelect.vue'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedValue } from '~/composables/usePersistedValue'
|
import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||||
|
import { constructeurPhones } from '~/shared/constructeurUtils'
|
||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
import { formatFrenchDate } from '~/utils/date'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
|
||||||
|
interface TelephoneFormRow { '@id'?: string, numero: string, label: string }
|
||||||
|
interface ConstructeurFormState {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
telephones: TelephoneFormRow[]
|
||||||
|
categories: ConstructeurCategorie[]
|
||||||
|
}
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
|
||||||
|
const { categories: allCategories, loadCategories } = useConstructeurCategories()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
|
|
||||||
|
const pageItems = ref<typeof constructeurs.value>([])
|
||||||
|
const totalItems = ref(0)
|
||||||
|
const totalPages = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const perPage = ref(30)
|
||||||
|
const perPageOptions = [15, 30, 50, 100]
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: 'Nom', sortable: true },
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
{ key: 'email', label: 'Email', sortable: true },
|
{ key: 'email', label: 'Email', sortable: true },
|
||||||
{ key: 'phone', label: 'Téléphone', sortable: true },
|
{ key: 'telephones', label: 'Téléphones' },
|
||||||
|
{ key: 'categories', label: 'Catégories' },
|
||||||
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
||||||
{ key: 'composantCount', label: 'Composants', align: 'center' },
|
{ key: 'composantCount', label: 'Composants', align: 'center' },
|
||||||
{ key: 'pieceCount', label: 'Pièces', align: 'center' },
|
{ key: 'pieceCount', label: 'Pièces', align: 'center' },
|
||||||
@@ -153,9 +257,10 @@ const columns = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
|
const selectedCategoryId = ref('')
|
||||||
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
const sortKey = usePersistedValue('constructeurs-sort', 'name')
|
||||||
const sortDir = ref('asc')
|
const sortDir = ref('asc')
|
||||||
const stats = ref({})
|
const stats = ref<Record<string, { composantCount?: number, pieceCount?: number, machineCount?: number }>>({})
|
||||||
|
|
||||||
const currentSort = computed(() => ({
|
const currentSort = computed(() => ({
|
||||||
field: sortKey.value,
|
field: sortKey.value,
|
||||||
@@ -167,40 +272,80 @@ const handleSort = (sort) => {
|
|||||||
sortDir.value = sort.direction
|
sortDir.value = sort.direction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paginationState = computed(() => ({
|
||||||
|
currentPage: currentPage.value,
|
||||||
|
totalPages: totalPages.value,
|
||||||
|
totalItems: totalItems.value,
|
||||||
|
pageItems: pageItems.value.length,
|
||||||
|
perPage: perPage.value,
|
||||||
|
perPageOptions,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
|
||||||
|
|
||||||
|
const loadPage = async () => {
|
||||||
|
const orderField = SORTABLE_FIELDS.has(sortKey.value)
|
||||||
|
? (sortKey.value as 'name' | 'email' | 'createdAt')
|
||||||
|
: 'name'
|
||||||
|
const result = await fetchConstructeursPage({
|
||||||
|
page: currentPage.value,
|
||||||
|
itemsPerPage: perPage.value,
|
||||||
|
search: searchTerm.value,
|
||||||
|
categoryId: selectedCategoryId.value || undefined,
|
||||||
|
orderField,
|
||||||
|
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
pageItems.value = []
|
||||||
|
totalItems.value = 0
|
||||||
|
totalPages.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageItems.value = result.items
|
||||||
|
totalItems.value = result.totalItems
|
||||||
|
totalPages.value = result.totalPages
|
||||||
|
if (currentPage.value > result.totalPages && result.totalPages > 0) {
|
||||||
|
currentPage.value = result.totalPages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modalOpen = ref(false)
|
const modalOpen = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const editingConstructeur = ref(null)
|
const editingConstructeur = ref<Record<string, any> | null>(null)
|
||||||
const form = ref({ name: '', email: '', phone: '' })
|
const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], categories: [] })
|
||||||
|
|
||||||
const filteredConstructeurs = computed(() => {
|
const rowPhones = constructeurPhones
|
||||||
const key = sortKey.value
|
|
||||||
const dir = sortDir.value === 'desc' ? -1 : 1
|
const debouncedSearch = debounce(() => {
|
||||||
const sorted = [...constructeurs.value].sort((a, b) => {
|
currentPage.value = 1
|
||||||
if (key === 'createdAt') {
|
loadPage()
|
||||||
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
|
}, 300)
|
||||||
}
|
|
||||||
return dir * (a[key] || '').localeCompare(b[key] || '')
|
watch(selectedCategoryId, () => {
|
||||||
})
|
currentPage.value = 1
|
||||||
if (!searchTerm.value) { return sorted }
|
loadPage()
|
||||||
const term = searchTerm.value.toLowerCase()
|
|
||||||
return sorted.filter(item =>
|
|
||||||
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const debouncedSearch = debounce(async () => {
|
watch([sortKey, sortDir], () => {
|
||||||
await searchConstructeurs(searchTerm.value)
|
currentPage.value = 1
|
||||||
}, 300)
|
loadPage()
|
||||||
|
})
|
||||||
|
|
||||||
|
const onPageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPerPageChange = (value: number) => {
|
||||||
|
perPage.value = value
|
||||||
|
currentPage.value = 1
|
||||||
|
loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
const formatDate = formatFrenchDate
|
const formatDate = formatFrenchDate
|
||||||
|
|
||||||
const formatPhoneDisplay = (value) => {
|
const formatPhoneDisplay = value => formatPhone(value) || value || '—'
|
||||||
const formatted = formatPhone(value)
|
|
||||||
if (formatted) {
|
|
||||||
return formatted
|
|
||||||
}
|
|
||||||
return value || '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
function debounce(fn, delay) {
|
function debounce(fn, delay) {
|
||||||
let timeout
|
let timeout
|
||||||
@@ -211,7 +356,7 @@ function debounce(fn, delay) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.value = { name: '', email: '', phone: '' }
|
form.value = { name: '', email: '', telephones: [], categories: [] }
|
||||||
editingConstructeur.value = null
|
editingConstructeur.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +370,12 @@ const openEditModal = (constructeur) => {
|
|||||||
form.value = {
|
form.value = {
|
||||||
name: constructeur.name,
|
name: constructeur.name,
|
||||||
email: constructeur.email || '',
|
email: constructeur.email || '',
|
||||||
phone: constructeur.phone || '',
|
telephones: (constructeur.telephones || []).map(t => ({
|
||||||
|
'@id': t['@id'],
|
||||||
|
numero: t.numero || '',
|
||||||
|
label: t.label || '',
|
||||||
|
})),
|
||||||
|
categories: (constructeur.categories || []).map(c => ({ ...c })),
|
||||||
}
|
}
|
||||||
modalOpen.value = true
|
modalOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -235,8 +385,20 @@ const closeModal = () => {
|
|||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addTelephoneRow = () => {
|
||||||
|
form.value.telephones.push({ numero: '', label: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTelephoneRow = (idx) => {
|
||||||
|
form.value.telephones.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
const saveConstructeur = async () => {
|
const saveConstructeur = async () => {
|
||||||
const trimmedName = form.value.name.trim()
|
const trimmedName = form.value.name.trim()
|
||||||
|
if (!trimmedName) {
|
||||||
|
showError('Le nom est obligatoire.')
|
||||||
|
return
|
||||||
|
}
|
||||||
const duplicate = constructeurs.value.find(
|
const duplicate = constructeurs.value.find(
|
||||||
c => c.name.toLowerCase() === trimmedName.toLowerCase()
|
c => c.name.toLowerCase() === trimmedName.toLowerCase()
|
||||||
&& c.id !== editingConstructeur.value?.id,
|
&& c.id !== editingConstructeur.value?.id,
|
||||||
@@ -247,9 +409,24 @@ const saveConstructeur = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
const payload = { ...form.value, name: trimmedName }
|
const payload = {
|
||||||
if (!payload.email) { delete payload.email }
|
name: trimmedName,
|
||||||
if (!payload.phone) { delete payload.phone }
|
email: form.value.email?.trim() || null,
|
||||||
|
telephones: form.value.telephones
|
||||||
|
.filter(t => t.numero && t.numero.trim())
|
||||||
|
.map((t) => {
|
||||||
|
const entry: { numero: string, label: string | null, '@id'?: string } = {
|
||||||
|
numero: t.numero.trim(),
|
||||||
|
label: t.label?.trim() || null,
|
||||||
|
}
|
||||||
|
if (t['@id']) { entry['@id'] = t['@id'] }
|
||||||
|
return entry
|
||||||
|
}),
|
||||||
|
categories: form.value.categories
|
||||||
|
.map(c => c['@id'] || (c.id ? `/api/constructeur_categories/${c.id}` : null))
|
||||||
|
.filter((iri): iri is string => Boolean(iri)),
|
||||||
|
}
|
||||||
|
|
||||||
let result
|
let result
|
||||||
if (editingConstructeur.value) {
|
if (editingConstructeur.value) {
|
||||||
result = await updateConstructeur(editingConstructeur.value.id, payload)
|
result = await updateConstructeur(editingConstructeur.value.id, payload)
|
||||||
@@ -260,7 +437,7 @@ const saveConstructeur = async () => {
|
|||||||
saving.value = false
|
saving.value = false
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
closeModal()
|
closeModal()
|
||||||
await searchConstructeurs(searchTerm.value)
|
await loadPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +448,10 @@ const confirmDelete = async (constructeur) => {
|
|||||||
const result = await deleteConstructeur(constructeur.id)
|
const result = await deleteConstructeur(constructeur.id)
|
||||||
if (!result.success && result.error) {
|
if (!result.success && result.error) {
|
||||||
showError(result.error)
|
showError(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.success) {
|
||||||
|
await loadPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +463,8 @@ const loadStats = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadConstructeurs()
|
loadPage()
|
||||||
|
loadCategories()
|
||||||
loadStats()
|
loadStats()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
1002
frontend/app/pages/doc.vue
Normal file
1002
frontend/app/pages/doc.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control md:w-64">
|
<div class="form-control md:w-48">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
|
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -58,6 +58,24 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="dateFrom"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-base-content/50">à</span>
|
||||||
|
<input
|
||||||
|
v-model="dateTo"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,6 +295,8 @@ const showAddSiteModal = ref(false)
|
|||||||
const showAddMachineModal = ref(false)
|
const showAddMachineModal = ref(false)
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const selectedSiteFilter = ref('')
|
const selectedSiteFilter = ref('')
|
||||||
|
const dateFrom = ref('')
|
||||||
|
const dateTo = ref('')
|
||||||
const collapsedSites = ref([])
|
const collapsedSites = ref([])
|
||||||
const preselectedSiteId = ref('')
|
const preselectedSiteId = ref('')
|
||||||
|
|
||||||
@@ -327,6 +347,25 @@ const filteredSites = computed(() => {
|
|||||||
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
|
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtrer les machines par date de création
|
||||||
|
if (dateFrom.value || dateTo.value) {
|
||||||
|
const from = dateFrom.value ? new Date(dateFrom.value) : null
|
||||||
|
const to = dateTo.value ? new Date(dateTo.value) : null
|
||||||
|
if (from) from.setHours(0, 0, 0, 0)
|
||||||
|
if (to) to.setHours(23, 59, 59, 999)
|
||||||
|
|
||||||
|
filtered = filtered.map((site) => {
|
||||||
|
const filteredMachines = (site.machines || []).filter((machine) => {
|
||||||
|
if (!machine.createdAt) return false
|
||||||
|
const created = new Date(machine.createdAt)
|
||||||
|
if (from && created < from) return false
|
||||||
|
if (to && created > to) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return { ...site, machines: filteredMachines }
|
||||||
|
}).filter(site => site.machines.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
// Filtrer par terme de recherche
|
// Filtrer par terme de recherche
|
||||||
if (searchTerm.value) {
|
if (searchTerm.value) {
|
||||||
filtered = filtered.filter((site) => {
|
filtered = filtered.filter((site) => {
|
||||||
|
|||||||
@@ -5,108 +5,93 @@
|
|||||||
<h2 class="text-2xl font-bold">
|
<h2 class="text-2xl font-bold">
|
||||||
Parc Machines
|
Parc Machines
|
||||||
</h2>
|
</h2>
|
||||||
<NuxtLink to="/machines/new" class="btn btn-primary">
|
<NuxtLink v-if="canEdit" to="/machines/new" class="btn btn-primary">
|
||||||
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
Ajouter une machine
|
Ajouter une machine
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-sm mb-6">
|
<div class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<DataTable
|
||||||
<div class="form-control">
|
:columns="columns"
|
||||||
<label class="label">
|
:rows="filteredMachines"
|
||||||
<span class="label-text">Sites</span>
|
:loading="loading"
|
||||||
</label>
|
:sort="currentSort"
|
||||||
<div class="flex flex-wrap gap-3">
|
:show-counter="true"
|
||||||
<label
|
empty-message="Aucune machine trouvée."
|
||||||
v-for="site in sites"
|
no-results-message="Aucune machine ne correspond à vos filtres."
|
||||||
:key="site.id"
|
@sort="handleSort"
|
||||||
class="flex items-center gap-2 cursor-pointer"
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<label class="w-full sm:w-72">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
class="input input-bordered input-sm w-full mt-1"
|
||||||
|
placeholder="Nom ou référence..."
|
||||||
>
|
>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox checkbox-sm"
|
|
||||||
:checked="selectedSites.has(site.id)"
|
|
||||||
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
|
|
||||||
>
|
|
||||||
<span class="text-sm">{{ site.name }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Recherche</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
<div class="flex flex-col">
|
||||||
type="text"
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Site</span>
|
||||||
placeholder="Rechercher par nom ou référence..."
|
<select v-model="selectedSiteId" class="select select-bordered select-sm mt-1">
|
||||||
class="input input-bordered"
|
<option value="">Tous les sites</option>
|
||||||
|
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||||
|
{{ site.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Date de création</span>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
v-model="dateFrom"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-base-content/50">à</span>
|
||||||
|
<input
|
||||||
|
v-model="dateTo"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-site="{ row }">
|
||||||
|
<span
|
||||||
|
v-if="row.site"
|
||||||
|
class="badge badge-sm font-bold"
|
||||||
|
:style="row.site.color ? { backgroundColor: row.site.color + '30', color: row.site.color, borderColor: row.site.color + '50' } : {}"
|
||||||
|
:class="!row.site.color ? 'badge-ghost' : ''"
|
||||||
>
|
>
|
||||||
</div>
|
{{ row.site.name }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span v-else class="text-base-content/30">—</span>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div v-if="loading" class="flex justify-center items-center py-12">
|
<template #cell-createdAt="{ row }">
|
||||||
<span class="loading loading-spinner loading-lg" />
|
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<EmptyState
|
<template #cell-actions="{ row }">
|
||||||
v-else-if="filteredMachines.length === 0"
|
<div class="flex items-center justify-end gap-2">
|
||||||
:icon="IconLucideFactory"
|
<button v-if="canEdit" class="btn btn-ghost btn-xs" @click="editMachine(row)">
|
||||||
title="Aucune machine trouvée"
|
Modifier
|
||||||
description="Commencez par ajouter votre première machine."
|
</button>
|
||||||
action-label="Ajouter une machine"
|
<button v-if="canEdit" class="btn btn-ghost btn-xs text-error" @click="confirmDeleteMachine(row)">
|
||||||
action-to="/machines/new"
|
Supprimer
|
||||||
/>
|
</button>
|
||||||
|
<NuxtLink :to="`/machine/${row.id}`" class="btn btn-primary btn-xs">
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
Détails
|
||||||
<div
|
</NuxtLink>
|
||||||
v-for="machine in filteredMachines"
|
|
||||||
:key="machine.id"
|
|
||||||
class="card site-card shadow-md hover:shadow-xl transition-shadow cursor-pointer overflow-hidden"
|
|
||||||
:style="{
|
|
||||||
borderTop: machine.site?.color ? `4px solid ${machine.site.color}` : '4px solid transparent',
|
|
||||||
background: machine.site?.color ? `linear-gradient(160deg, ${machine.site.color}30 0%, ${machine.site.color}08 40%, var(--color-base-100) 100%)` : undefined,
|
|
||||||
}"
|
|
||||||
@click="viewMachineDetails(machine)"
|
|
||||||
>
|
|
||||||
<div class="card-body flex flex-col">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h3 class="card-title text-lg">
|
|
||||||
{{ machine.name }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<IconLucideMapPin class="w-4 h-4" :style="{ color: machine.site?.color || '#3b82f6' }" aria-hidden="true" />
|
|
||||||
<span
|
|
||||||
class="font-bold text-sm px-2.5 py-1 rounded-lg text-base-content"
|
|
||||||
:style="machine.site?.color ? { backgroundColor: machine.site.color + '30', border: `1px solid ${machine.site.color}40` } : {}"
|
|
||||||
>{{ machine.site?.name || 'Site inconnu' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<div v-if="machine.reference" class="flex items-center gap-2">
|
</DataTable>
|
||||||
<IconLucideTag class="w-4 h-4 text-orange-500" aria-hidden="true" />
|
|
||||||
<span class="text-gray-600">{{ machine.reference }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto pt-3 flex items-center justify-end gap-2">
|
|
||||||
<button v-if="canEdit" class="btn btn-ghost btn-sm" @click.stop="editMachine(machine)">
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
<button v-if="canEdit" class="btn btn-ghost btn-sm text-error" @click.stop="confirmDeleteMachine(machine)">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-primary btn-sm">
|
|
||||||
Détails
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,16 +99,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
|
import { usePersistedValue } from '~/composables/usePersistedValue'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideFactory from '~icons/lucide/factory'
|
|
||||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
|
||||||
import IconLucideTag from '~icons/lucide/tag'
|
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
||||||
@@ -132,34 +117,46 @@ const toast = useToast()
|
|||||||
|
|
||||||
const urlState = useUrlState({
|
const urlState = useUrlState({
|
||||||
q: { default: '', debounce: 300 },
|
q: { default: '', debounce: 300 },
|
||||||
sites: { default: '' },
|
site: { default: '' },
|
||||||
|
from: { default: '' },
|
||||||
|
to: { default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchQuery = urlState.q
|
const searchQuery = urlState.q
|
||||||
const selectedSites = reactive(new Set())
|
const selectedSiteId = urlState.site
|
||||||
|
const dateFrom = urlState.from
|
||||||
|
const dateTo = urlState.to
|
||||||
|
|
||||||
// Sync URL → selectedSites on load and back/forward
|
const sortKey = usePersistedValue('machines-sort', 'name')
|
||||||
watch(urlState.sites, (val) => {
|
const sortDir = ref('asc')
|
||||||
selectedSites.clear()
|
|
||||||
if (val) {
|
|
||||||
for (const id of String(val).split(',')) {
|
|
||||||
if (id) selectedSites.add(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// Sync selectedSites → URL
|
const currentSort = computed(() => ({
|
||||||
watch(() => [...selectedSites], (ids) => {
|
field: sortKey.value,
|
||||||
urlState.sites.value = ids.join(',')
|
direction: sortDir.value,
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
const handleSort = (sort) => {
|
||||||
|
sortKey.value = sort.field
|
||||||
|
sortDir.value = sort.direction
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
|
{ key: 'reference', label: 'Référence', sortable: true },
|
||||||
|
{ key: 'site', label: 'Site', sortable: true, sortKey: 'siteName' },
|
||||||
|
{ key: 'createdAt', label: 'Date de création', sortable: true },
|
||||||
|
{ key: 'actions', label: 'Actions', align: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const formatDate = formatFrenchDate
|
||||||
|
|
||||||
// Enrichir les machines avec les objets site complets
|
|
||||||
const enrichedMachines = computed(() => {
|
const enrichedMachines = computed(() => {
|
||||||
return machines.value.map((machine) => {
|
return machines.value.map((machine) => {
|
||||||
const site = sites.value.find(s => s.id === machine.siteId)
|
const site = sites.value.find(s => s.id === machine.siteId)
|
||||||
return {
|
return {
|
||||||
...machine,
|
...machine,
|
||||||
site: site || null,
|
site: site || null,
|
||||||
|
siteName: site?.name || '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -167,29 +164,44 @@ const enrichedMachines = computed(() => {
|
|||||||
const filteredMachines = computed(() => {
|
const filteredMachines = computed(() => {
|
||||||
let filtered = enrichedMachines.value
|
let filtered = enrichedMachines.value
|
||||||
|
|
||||||
if (selectedSites.size > 0) {
|
if (selectedSiteId.value) {
|
||||||
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
|
filtered = filtered.filter(m => m.siteId === selectedSiteId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery.value.trim()) {
|
if (searchQuery.value.trim()) {
|
||||||
const term = searchQuery.value.trim().toLowerCase()
|
const term = searchQuery.value.trim().toLowerCase()
|
||||||
filtered = filtered.filter(machine =>
|
filtered = filtered.filter(m =>
|
||||||
machine.name?.toLowerCase().includes(term)
|
m.name?.toLowerCase().includes(term)
|
||||||
|| machine.reference?.toLowerCase().includes(term),
|
|| m.reference?.toLowerCase().includes(term),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered = [...filtered].sort((a, b) =>
|
if (dateFrom.value) {
|
||||||
(a.name || '').localeCompare(b.name || '', 'fr')
|
const from = new Date(dateFrom.value)
|
||||||
)
|
from.setHours(0, 0, 0, 0)
|
||||||
|
filtered = filtered.filter(m => m.createdAt && new Date(m.createdAt) >= from)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateTo.value) {
|
||||||
|
const to = new Date(dateTo.value)
|
||||||
|
to.setHours(23, 59, 59, 999)
|
||||||
|
filtered = filtered.filter(m => m.createdAt && new Date(m.createdAt) <= to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = sortKey.value
|
||||||
|
const dir = sortDir.value === 'desc' ? -1 : 1
|
||||||
|
filtered = [...filtered].sort((a, b) => {
|
||||||
|
if (key === 'createdAt') {
|
||||||
|
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
|
||||||
|
}
|
||||||
|
const valA = (key === 'siteName' ? a.siteName : a[key]) || ''
|
||||||
|
const valB = (key === 'siteName' ? b.siteName : b[key]) || ''
|
||||||
|
return dir * String(valA).localeCompare(String(valB), 'fr')
|
||||||
|
})
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewMachineDetails = (machine) => {
|
|
||||||
navigateTo(`/machine/${machine.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const editMachine = (machine) => {
|
const editMachine = (machine) => {
|
||||||
navigateTo(`/machine/${machine.id}?edit=true`)
|
navigateTo(`/machine/${machine.id}?edit=true`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
|||||||
await updateModelType(id, enrichedPayload)
|
await updateModelType(id, enrichedPayload)
|
||||||
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
|
||||||
await loadPieceTypes({ force: true })
|
await loadPieceTypes({ force: true })
|
||||||
|
await loadCategory()
|
||||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -181,6 +182,7 @@ const handleSyncConfirm = async () => {
|
|||||||
confirmTypeChanges: !!hasModifications,
|
confirmTypeChanges: !!hasModifications,
|
||||||
})
|
})
|
||||||
await loadPieceTypes({ force: true })
|
await loadPieceTypes({ force: true })
|
||||||
|
await loadCategory()
|
||||||
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
showSuccess('Catégorie de pièce mise à jour avec succès.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
|
|||||||
@@ -261,7 +261,7 @@
|
|||||||
:model-value="productSelections[entry.index] || null"
|
:model-value="productSelections[entry.index] || null"
|
||||||
:disabled="!canEdit || saving"
|
:disabled="!canEdit || saving"
|
||||||
:type-product-id="entry.typeProductId"
|
:type-product-id="entry.typeProductId"
|
||||||
helper-text="Un produit valide est requis pour cette pièce."
|
helper-text="Sélectionnez un produit (optionnel)."
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,6 +359,9 @@
|
|||||||
</header>
|
</header>
|
||||||
<template v-if="isEditMode">
|
<template v-if="isEditMode">
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -420,6 +423,9 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -460,6 +466,7 @@ const {
|
|||||||
constructeurLinks,
|
constructeurLinks,
|
||||||
productSelections,
|
productSelections,
|
||||||
customFieldInputs,
|
customFieldInputs,
|
||||||
|
requiredCustomFieldsFilled,
|
||||||
pieceTypeList,
|
pieceTypeList,
|
||||||
selectedType,
|
selectedType,
|
||||||
resolvedStructure,
|
resolvedStructure,
|
||||||
@@ -481,6 +488,8 @@ const {
|
|||||||
formatPieceStructurePreview,
|
formatPieceStructurePreview,
|
||||||
} = usePieceEdit(String(route.params.id))
|
} = usePieceEdit(String(route.params.id))
|
||||||
|
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
const entityTabs = computed(() => [
|
const entityTabs = computed(() => [
|
||||||
{ key: 'general', label: 'Général' },
|
{ key: 'general', label: 'Général' },
|
||||||
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
|
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
:model-value="productSelections[entry.index] || null"
|
:model-value="productSelections[entry.index] || null"
|
||||||
:disabled="!canEdit || submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
:type-product-id="entry.typeProductId"
|
:type-product-id="entry.typeProductId"
|
||||||
helper-text="Un produit est requis pour cette pièce."
|
helper-text="Sélectionnez un produit (optionnel)."
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,6 +218,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else
|
v-else
|
||||||
@@ -237,6 +240,9 @@
|
|||||||
Créer la pièce
|
Créer la pièce
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
|
Merci de renseigner tous les champs personnalisés obligatoires avant de créer la pièce.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -267,7 +273,6 @@ import {
|
|||||||
buildProductRequirementDescriptions,
|
buildProductRequirementDescriptions,
|
||||||
buildProductRequirementEntries,
|
buildProductRequirementEntries,
|
||||||
resizeProductSelections,
|
resizeProductSelections,
|
||||||
areProductSelectionsFilled,
|
|
||||||
applyProductSelection,
|
applyProductSelection,
|
||||||
collectNormalizedProductIds,
|
collectNormalizedProductIds,
|
||||||
} from '~/shared/utils/pieceProductSelectionUtils'
|
} from '~/shared/utils/pieceProductSelectionUtils'
|
||||||
@@ -311,7 +316,9 @@ const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, s
|
|||||||
values: [] as any[],
|
values: [] as any[],
|
||||||
entityType: 'piece' as CustomFieldEntityType,
|
entityType: 'piece' as CustomFieldEntityType,
|
||||||
entityId: createdEntityId,
|
entityId: createdEntityId,
|
||||||
|
context: 'standalone',
|
||||||
})
|
})
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
const selectedDocuments = ref<File[]>([])
|
const selectedDocuments = ref<File[]>([])
|
||||||
const uploadingDocuments = ref(false)
|
const uploadingDocuments = ref(false)
|
||||||
|
|
||||||
@@ -371,13 +378,7 @@ const productRequirementEntries = computed(() =>
|
|||||||
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
|
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const productSelectionsFilled = computed(() =>
|
const productSelectionsFilled = computed(() => true)
|
||||||
areProductSelectionsFilled(
|
|
||||||
requiresProductSelection.value,
|
|
||||||
productRequirementEntries.value,
|
|
||||||
productSelections.value,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const setProductSelection = (index: number, value: string | null) => {
|
const setProductSelection = (index: number, value: string | null) => {
|
||||||
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
productSelections.value = applyProductSelection(productSelections.value, index, value)
|
||||||
@@ -436,11 +437,6 @@ const submitCreation = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!productSelectionsFilled.value) {
|
|
||||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
name: creationForm.name.trim(),
|
name: creationForm.name.trim(),
|
||||||
typePieceId: selectedType.value.id,
|
typePieceId: selectedType.value.id,
|
||||||
|
|||||||
@@ -274,6 +274,9 @@
|
|||||||
</header>
|
</header>
|
||||||
<template v-if="isEditMode">
|
<template v-if="isEditMode">
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -338,7 +341,7 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="isEditMode && product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,6 +412,7 @@ const {
|
|||||||
values: cfValues,
|
values: cfValues,
|
||||||
entityType: 'product' as CustomFieldEntityType,
|
entityType: 'product' as CustomFieldEntityType,
|
||||||
entityId,
|
entityId,
|
||||||
|
context: 'standalone',
|
||||||
})
|
})
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -446,7 +450,7 @@ const editionForm = reactive({
|
|||||||
supplierPrice: '' as string,
|
supplierPrice: '' as string,
|
||||||
})
|
})
|
||||||
|
|
||||||
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
||||||
|
|||||||
@@ -158,6 +158,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
|
||||||
|
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
|
||||||
|
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else
|
v-else
|
||||||
@@ -177,7 +180,7 @@
|
|||||||
Créer le produit
|
Créer le produit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="selectedType && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,7 +244,9 @@ const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, s
|
|||||||
values: [] as any[],
|
values: [] as any[],
|
||||||
entityType: 'product' as CustomFieldEntityType,
|
entityType: 'product' as CustomFieldEntityType,
|
||||||
entityId: createdEntityId,
|
entityId: createdEntityId,
|
||||||
|
context: 'standalone',
|
||||||
})
|
})
|
||||||
|
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
|
||||||
|
|
||||||
const productTypeList = computed<ProductCatalogType[]>(() =>
|
const productTypeList = computed<ProductCatalogType[]>(() =>
|
||||||
(productTypes.value || []) as ProductCatalogType[],
|
(productTypes.value || []) as ProductCatalogType[],
|
||||||
|
|||||||
@@ -1,12 +1,49 @@
|
|||||||
import { formatPhone } from '~/utils/formatters/phone';
|
import { formatPhone } from '~/utils/formatters/phone';
|
||||||
|
|
||||||
|
export interface ConstructeurTelephoneSummary {
|
||||||
|
numero?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConstructeurSummary {
|
export interface ConstructeurSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
// Legacy single-phone string: still exposed by the machine-structure normalization.
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
|
// Multi-phone list: exposed by the /constructeurs API resource.
|
||||||
|
telephones?: ConstructeurTelephoneSummary[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConstructeurPhoneSource = {
|
||||||
|
phone?: string | null;
|
||||||
|
telephones?: ConstructeurTelephoneSummary[] | null;
|
||||||
|
} | null | undefined;
|
||||||
|
|
||||||
|
export const constructeurPhones = (
|
||||||
|
constructeur: ConstructeurPhoneSource,
|
||||||
|
): Array<{ numero: string; label: string | null }> => {
|
||||||
|
if (!constructeur) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const list = Array.isArray(constructeur.telephones)
|
||||||
|
? constructeur.telephones
|
||||||
|
.filter((t): t is ConstructeurTelephoneSummary => Boolean(t && t.numero && String(t.numero).trim()))
|
||||||
|
.map(t => ({ numero: String(t.numero).trim(), label: (t.label ?? null) || null }))
|
||||||
|
: [];
|
||||||
|
if (!list.length && constructeur.phone && constructeur.phone.trim()) {
|
||||||
|
return [{ numero: constructeur.phone.trim(), label: null }];
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const constructeurPrimaryPhone = (
|
||||||
|
constructeur: ConstructeurPhoneSource,
|
||||||
|
): string | null => {
|
||||||
|
const phones = constructeurPhones(constructeur);
|
||||||
|
return phones.length ? phones[0]!.numero : null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ConstructeurLinkEntry {
|
export interface ConstructeurLinkEntry {
|
||||||
linkId?: string;
|
linkId?: string;
|
||||||
constructeurId: string;
|
constructeurId: string;
|
||||||
@@ -133,8 +170,8 @@ export const formatConstructeurContact = (
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedPhone = formatPhone(constructeur.phone);
|
const primary = constructeurPrimaryPhone(constructeur);
|
||||||
const phone = formattedPhone || constructeur.phone || null;
|
const phone = formatPhone(primary) || primary || null;
|
||||||
|
|
||||||
return [constructeur.email, phone].filter(Boolean).join(' • ');
|
return [constructeur.email, phone].filter(Boolean).join(' • ');
|
||||||
};
|
};
|
||||||
|
|||||||
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@@ -98,6 +98,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
|
||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -2092,6 +2093,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -4112,6 +4114,7 @@
|
|||||||
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
|
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -4181,6 +4184,7 @@
|
|||||||
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
|
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.44.1",
|
"@typescript-eslint/scope-manager": "8.44.1",
|
||||||
"@typescript-eslint/types": "8.44.1",
|
"@typescript-eslint/types": "8.44.1",
|
||||||
@@ -4977,6 +4981,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
||||||
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.4",
|
"@babel/parser": "^7.28.4",
|
||||||
"@vue/compiler-core": "3.5.22",
|
"@vue/compiler-core": "3.5.22",
|
||||||
@@ -5207,6 +5212,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5637,6 +5643,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.3",
|
"baseline-browser-mapping": "^2.8.3",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
@@ -7069,6 +7076,7 @@
|
|||||||
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
|
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -10490,6 +10498,7 @@
|
|||||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deep-is": "^0.1.3",
|
"deep-is": "^0.1.3",
|
||||||
"fast-levenshtein": "^2.0.6",
|
"fast-levenshtein": "^2.0.6",
|
||||||
@@ -10536,6 +10545,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.87.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.87.0.tgz",
|
||||||
"integrity": "sha512-uc47XrtHwkBoES4HFgwgfH9sqwAtJXgAIBq4fFBMZ4hWmgVZoExyn+L4g4VuaecVKXkz1bvlaHcfwHAJPQb5Gw==",
|
"integrity": "sha512-uc47XrtHwkBoES4HFgwgfH9sqwAtJXgAIBq4fFBMZ4hWmgVZoExyn+L4g4VuaecVKXkz1bvlaHcfwHAJPQb5Gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "^0.87.0"
|
"@oxc-project/types": "^0.87.0"
|
||||||
},
|
},
|
||||||
@@ -10937,6 +10947,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -11376,6 +11387,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
|
||||||
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
|
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssesc": "^3.0.0",
|
"cssesc": "^3.0.0",
|
||||||
"util-deprecate": "^1.0.2"
|
"util-deprecate": "^1.0.2"
|
||||||
@@ -12118,6 +12130,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
|
||||||
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -13180,6 +13193,7 @@
|
|||||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -13537,6 +13551,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"napi-postinstall": "^0.3.0"
|
"napi-postinstall": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -13783,6 +13798,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
||||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -14186,6 +14202,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.22",
|
"@vue/compiler-dom": "3.5.22",
|
||||||
"@vue/compiler-sfc": "3.5.22",
|
"@vue/compiler-sfc": "3.5.22",
|
||||||
@@ -14230,6 +14247,7 @@
|
|||||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"eslint-scope": "^8.2.0",
|
"eslint-scope": "^8.2.0",
|
||||||
@@ -14253,6 +14271,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||||
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
|
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^6.6.4"
|
"@vue/devtools-api": "^6.6.4"
|
||||||
},
|
},
|
||||||
|
|||||||
737
frontend/tests/composables/useComponentCreate.test.ts
Normal file
737
frontend/tests/composables/useComponentCreate.test.ts
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { mockLinkSKF, mockLinkFAG } from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import under test (AFTER all vi.mock calls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useComponentCreate } from '~/composables/useComponentCreate'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — API layer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
const mockPostFormData = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: mockPostFormData,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — Toast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockShowSuccess = vi.fn()
|
||||||
|
const mockShowError = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: mockShowSuccess,
|
||||||
|
showError: mockShowError,
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useComposants (createComposant)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockCreateComposant = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComposants', () => ({
|
||||||
|
useComposants: () => ({
|
||||||
|
createComposant: mockCreateComposant,
|
||||||
|
composants: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePieces, useProducts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieces', () => ({
|
||||||
|
usePieces: () => ({
|
||||||
|
pieces: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useProducts', () => ({
|
||||||
|
useProducts: () => ({
|
||||||
|
products: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useComponentTypes, usePieceTypes, useProductTypes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockLoadComponentTypes = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const mockComponentTypes = { value: [] as any[] }
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComponentTypes', () => ({
|
||||||
|
useComponentTypes: () => ({
|
||||||
|
componentTypes: mockComponentTypes,
|
||||||
|
loadComponentTypes: mockLoadComponentTypes,
|
||||||
|
loadingComponentTypes: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieceTypes', () => ({
|
||||||
|
usePieceTypes: () => ({
|
||||||
|
pieceTypes: { value: [] },
|
||||||
|
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useProductTypes', () => ({
|
||||||
|
useProductTypes: () => ({
|
||||||
|
productTypes: { value: [] },
|
||||||
|
loadProductTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useDocuments (uploadDocuments)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUploadDocuments = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useDocuments', () => ({
|
||||||
|
useDocuments: () => ({
|
||||||
|
uploadDocuments: mockUploadDocuments,
|
||||||
|
documents: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurLinks (syncLinks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurLinks', () => ({
|
||||||
|
useConstructeurLinks: () => ({
|
||||||
|
fetchLinks: vi.fn().mockResolvedValue([]),
|
||||||
|
syncLinks: mockSyncLinks,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useCustomFieldInputs (saveAll)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockSaveAll = vi.fn().mockResolvedValue([])
|
||||||
|
const mockRefreshCF = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFieldInputs', () => ({
|
||||||
|
useCustomFieldInputs: () => ({
|
||||||
|
fields: { value: [] },
|
||||||
|
requiredFilled: { value: true },
|
||||||
|
saveAll: mockSaveAll,
|
||||||
|
refresh: mockRefreshCF,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePermissions (auto-imported in Nuxt)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// usePermissions is Nuxt auto-imported (no explicit import in source),
|
||||||
|
// so we stub it as a global function.
|
||||||
|
vi.stubGlobal('usePermissions', () => ({
|
||||||
|
canEdit: { value: true },
|
||||||
|
canManage: { value: true },
|
||||||
|
isAdmin: { value: false },
|
||||||
|
isGranted: () => true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurs (used by useComposants internally)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — shared utils that touch structure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockHasAssignments = vi.fn().mockReturnValue(false)
|
||||||
|
const mockSerializeStructureAssignments = vi.fn().mockReturnValue(null)
|
||||||
|
const mockIsAssignmentNodeComplete = vi.fn().mockReturnValue(true)
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/structureAssignmentHelpers', () => ({
|
||||||
|
hasAssignments: (...args: any[]) => mockHasAssignments(...args),
|
||||||
|
initializeStructureAssignments: () => null,
|
||||||
|
isAssignmentNodeComplete: (...args: any[]) => mockIsAssignmentNodeComplete(...args),
|
||||||
|
serializeStructureAssignments: (...args: any[]) => mockSerializeStructureAssignments(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/structureDisplayUtils', () => ({
|
||||||
|
getStructurePieces: () => [],
|
||||||
|
resolvePieceLabel: (p: any) => p?.name ?? '',
|
||||||
|
resolveProductLabel: (p: any) => p?.name ?? '',
|
||||||
|
resolveSubcomponentLabel: (p: any) => p?.name ?? '',
|
||||||
|
fetchModelTypeNames: vi.fn().mockResolvedValue({}),
|
||||||
|
buildTypeLabelMap: () => ({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/modelUtils', () => ({
|
||||||
|
formatStructurePreview: () => '',
|
||||||
|
normalizeStructureForEditor: (s: any) => s,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/errorMessages', () => ({
|
||||||
|
humanizeError: (msg: string) => msg,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/constructeurUtils', () => ({
|
||||||
|
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
|
||||||
|
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A minimal ModelType matching the `COMPONENT` category filter. */
|
||||||
|
const mockModelType = {
|
||||||
|
id: 'tc-moteur',
|
||||||
|
name: 'Moteur électrique',
|
||||||
|
category: 'COMPONENT',
|
||||||
|
structure: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Provide at least one COMPONENT type so selectedType resolves
|
||||||
|
mockComponentTypes.value = [mockModelType]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// submitCreation — payload completeness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitCreation — payload completeness', () => {
|
||||||
|
it('includes all form fields in createComposant payload', async () => {
|
||||||
|
const createdComp = { id: 'comp-new-001', name: 'Moteur principal' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
|
||||||
|
// Select a type
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
// Wait a tick so watchers fire
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
// Fill form fields
|
||||||
|
composable.creationForm.name = 'Moteur principal'
|
||||||
|
composable.creationForm.description = 'Un moteur triphasé'
|
||||||
|
composable.creationForm.reference = 'MOT-001'
|
||||||
|
composable.creationForm.prix = '1500'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
name: 'Moteur principal',
|
||||||
|
description: 'Un moteur triphasé',
|
||||||
|
reference: 'MOT-001',
|
||||||
|
prix: '1500',
|
||||||
|
typeComposantId: 'tc-moteur',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves custom fields after component creation (saveAll is called)', async () => {
|
||||||
|
const createdComp = { id: 'comp-cf-001', name: 'Composant CF' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant CF'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs constructeur links after creation with correct entity type and ID', async () => {
|
||||||
|
const createdComp = { id: 'comp-link-001', name: 'Composant Links' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Links'
|
||||||
|
// Add constructeur links
|
||||||
|
composable.constructeurLinks.value = [mockLinkSKF, mockLinkFAG]
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledWith(
|
||||||
|
'composant',
|
||||||
|
'comp-link-001',
|
||||||
|
[],
|
||||||
|
[mockLinkSKF, mockLinkFAG],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads documents with correct composantId context', async () => {
|
||||||
|
const createdComp = { id: 'comp-doc-001', name: 'Composant Docs' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Docs'
|
||||||
|
|
||||||
|
// Simulate selected documents
|
||||||
|
const fakeFile = new File(['content'], 'schema.pdf', { type: 'application/pdf' })
|
||||||
|
composable.selectedDocuments.value = [fakeFile]
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockUploadDocuments).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockUploadDocuments).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
files: [fakeFile],
|
||||||
|
context: { composantId: 'comp-doc-001' },
|
||||||
|
},
|
||||||
|
{ updateStore: false },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not crash with zero constructeurs', async () => {
|
||||||
|
const createdComp = { id: 'comp-no-cstr', name: 'Composant Simple' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Simple'
|
||||||
|
// Ensure no constructeur links
|
||||||
|
composable.constructeurLinks.value = []
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
||||||
|
expect(mockShowSuccess).toHaveBeenCalledWith('Composant créé avec succès')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Structure serialization in payload
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitCreation — structure serialization in payload', () => {
|
||||||
|
it('includes structure key with serialized data when assignments exist', async () => {
|
||||||
|
const createdComp = { id: 'comp-struct-001', name: 'Composant Structure' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const fakeSerializedStructure = {
|
||||||
|
path: 'root',
|
||||||
|
definition: { typeComposantId: 'tc-moteur' },
|
||||||
|
pieces: [{ path: 'root:piece-0', definition: { typePieceId: 'tp-001' }, selectedPieceId: 'piece-abc' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(true)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(fakeSerializedStructure)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Structure'
|
||||||
|
// Set a non-null structureAssignments so the composable considers it present
|
||||||
|
composable.structureAssignments.value = {
|
||||||
|
path: 'root',
|
||||||
|
definition: {} as any,
|
||||||
|
selectedComponentId: '',
|
||||||
|
pieces: [{ path: 'root:piece-0', definition: {} as any, selectedPieceId: 'piece-abc' }],
|
||||||
|
products: [],
|
||||||
|
subcomponents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload.structure).toEqual(fakeSerializedStructure)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include structure key when no assignments exist', async () => {
|
||||||
|
const createdComp = { id: 'comp-nostruct-001', name: 'Composant No Structure' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
// Reset to default: no assignments
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant No Structure'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload.structure).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include structure key when serializeStructureAssignments returns null', async () => {
|
||||||
|
const createdComp = { id: 'comp-sernull-001', name: 'Composant Serialize Null' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(true)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Serialize Null'
|
||||||
|
composable.structureAssignments.value = {
|
||||||
|
path: 'root',
|
||||||
|
definition: {} as any,
|
||||||
|
selectedComponentId: '',
|
||||||
|
pieces: [],
|
||||||
|
products: [],
|
||||||
|
subcomponents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload.structure).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prix / reference null handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitCreation — prix and reference null handling', () => {
|
||||||
|
it('does not send prix when prix is an empty string', async () => {
|
||||||
|
const createdComp = { id: 'comp-noprix-001', name: 'Composant No Prix' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
// Reset structure mocks to default
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant No Prix'
|
||||||
|
composable.creationForm.prix = ''
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('prix')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send prix when prix is non-numeric (avoids NaN)', async () => {
|
||||||
|
const createdComp = { id: 'comp-nanprix-001', name: 'Composant NaN Prix' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant NaN Prix'
|
||||||
|
composable.creationForm.prix = 'not-a-number'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('prix')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends prix as stringified number when valid numeric string', async () => {
|
||||||
|
const createdComp = { id: 'comp-validprix-001', name: 'Composant Valid Prix' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Valid Prix'
|
||||||
|
composable.creationForm.prix = ' 42.5 '
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload.prix).toBe('42.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send reference when reference is an empty string', async () => {
|
||||||
|
const createdComp = { id: 'comp-noref-001', name: 'Composant No Ref' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant No Ref'
|
||||||
|
composable.creationForm.reference = ''
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('reference')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send reference when reference is whitespace only', async () => {
|
||||||
|
const createdComp = { id: 'comp-wsref-001', name: 'Composant WS Ref' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant WS Ref'
|
||||||
|
composable.creationForm.reference = ' '
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('reference')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitCreation — error paths', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not save custom fields when createComposant returns success: false', async () => {
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: false, error: 'Duplicate name' })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Fail'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith('Duplicate name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows toast error when createComposant returns success: false with error message', async () => {
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: false, error: 'Server validation failed' })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Error'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith('Server validation failed')
|
||||||
|
expect(mockShowSuccess).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows warning for failed custom fields but still navigates (composant exists)', async () => {
|
||||||
|
const createdComp = { id: 'comp-cf-warn-001', name: 'Composant CF Warn' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
mockSaveAll.mockResolvedValue(['Tension nominale', 'Certifié CE'])
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant CF Warn'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
// Custom field error toast is shown
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith(
|
||||||
|
'Erreur sur les champs : Tension nominale, Certifié CE',
|
||||||
|
)
|
||||||
|
// But creation success toast is also shown (composant was created)
|
||||||
|
expect(mockShowSuccess).toHaveBeenCalledWith('Composant créé avec succès')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('catches thrown exceptions and shows humanized error', async () => {
|
||||||
|
mockCreateComposant.mockRejectedValue(new Error('Network timeout'))
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Throw'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith('Network timeout')
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets submitting flag after failure', async () => {
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: false, error: 'fail' })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Reset Flag'
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
expect(composable.submitting.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ProductId from structure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitCreation — productId from structure', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockHasAssignments.mockReturnValue(false)
|
||||||
|
mockSerializeStructureAssignments.mockReturnValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes productId in payload when root product selection exists', async () => {
|
||||||
|
const createdComp = { id: 'comp-prodid-001', name: 'Composant ProductId' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant ProductId'
|
||||||
|
// Set structure assignments with a root product selection
|
||||||
|
composable.structureAssignments.value = {
|
||||||
|
path: 'root',
|
||||||
|
definition: {} as any,
|
||||||
|
selectedComponentId: '',
|
||||||
|
pieces: [],
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
path: 'root:product-0',
|
||||||
|
definition: { typeProductId: 'tprod-001' } as any,
|
||||||
|
selectedProductId: 'prod-selected-123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subcomponents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload.productId).toBe('prod-selected-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include productId when no root product is selected', async () => {
|
||||||
|
const createdComp = { id: 'comp-noprodid-001', name: 'Composant No ProductId' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant No ProductId'
|
||||||
|
composable.structureAssignments.value = {
|
||||||
|
path: 'root',
|
||||||
|
definition: {} as any,
|
||||||
|
selectedComponentId: '',
|
||||||
|
pieces: [],
|
||||||
|
products: [],
|
||||||
|
subcomponents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('productId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include productId when product selection is empty string', async () => {
|
||||||
|
const createdComp = { id: 'comp-emptyprod-001', name: 'Composant Empty Product' }
|
||||||
|
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
|
||||||
|
|
||||||
|
const composable = useComponentCreate()
|
||||||
|
composable.selectedTypeId.value = 'tc-moteur'
|
||||||
|
await new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
composable.creationForm.name = 'Composant Empty Product'
|
||||||
|
composable.structureAssignments.value = {
|
||||||
|
path: 'root',
|
||||||
|
definition: {} as any,
|
||||||
|
selectedComponentId: '',
|
||||||
|
pieces: [],
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
path: 'root:product-0',
|
||||||
|
definition: { typeProductId: 'tprod-001' } as any,
|
||||||
|
selectedProductId: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subcomponents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await composable.submitCreation()
|
||||||
|
|
||||||
|
const payload = mockCreateComposant.mock.calls[0]![0]
|
||||||
|
expect(payload).not.toHaveProperty('productId')
|
||||||
|
})
|
||||||
|
})
|
||||||
890
frontend/tests/composables/useComponentEdit.test.ts
Normal file
890
frontend/tests/composables/useComponentEdit.test.ts
Normal file
@@ -0,0 +1,890 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
mockComponentFromApi,
|
||||||
|
mockLinkSKF,
|
||||||
|
mockLinkFAG,
|
||||||
|
mockConstructeurSKF,
|
||||||
|
wrapCollection,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import under test (AFTER all vi.mock calls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useComponentEdit } from '~/composables/useComponentEdit'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — API layer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
const mockPostFormData = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: mockPostFormData,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — Toast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockShowSuccess = vi.fn()
|
||||||
|
const mockShowError = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: mockShowSuccess,
|
||||||
|
showError: mockShowError,
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useComposants (updateComposant)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUpdateComposant = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComposants', () => ({
|
||||||
|
useComposants: () => ({
|
||||||
|
updateComposant: mockUpdateComposant,
|
||||||
|
composants: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePieces, useProducts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieces', () => ({
|
||||||
|
usePieces: () => ({
|
||||||
|
pieces: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useProducts', () => ({
|
||||||
|
useProducts: () => ({
|
||||||
|
products: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useComponentTypes, usePieceTypes, useProductTypes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockLoadComponentTypes = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const mockComponentTypes = { value: [] as any[] }
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComponentTypes', () => ({
|
||||||
|
useComponentTypes: () => ({
|
||||||
|
componentTypes: mockComponentTypes,
|
||||||
|
loadComponentTypes: mockLoadComponentTypes,
|
||||||
|
loadingComponentTypes: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieceTypes', () => ({
|
||||||
|
usePieceTypes: () => ({
|
||||||
|
pieceTypes: { value: [] },
|
||||||
|
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useProductTypes', () => ({
|
||||||
|
useProductTypes: () => ({
|
||||||
|
productTypes: { value: [] },
|
||||||
|
loadProductTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useDocuments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockLoadDocumentsByComponent = vi.fn().mockResolvedValue({ success: true, data: [] })
|
||||||
|
const mockUploadDocuments = vi.fn().mockResolvedValue({ success: true, data: [] })
|
||||||
|
const mockDeleteDocument = vi.fn().mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
vi.mock('~/composables/useDocuments', () => ({
|
||||||
|
useDocuments: () => ({
|
||||||
|
loadDocumentsByComponent: mockLoadDocumentsByComponent,
|
||||||
|
uploadDocuments: mockUploadDocuments,
|
||||||
|
deleteDocument: mockDeleteDocument,
|
||||||
|
documents: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurLinks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockFetchLinks = vi.fn().mockResolvedValue([])
|
||||||
|
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurLinks', () => ({
|
||||||
|
useConstructeurLinks: () => ({
|
||||||
|
fetchLinks: mockFetchLinks,
|
||||||
|
syncLinks: mockSyncLinks,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useCustomFieldInputs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockSaveAll = vi.fn().mockResolvedValue([])
|
||||||
|
const mockRefreshCF = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFieldInputs', () => ({
|
||||||
|
useCustomFieldInputs: () => ({
|
||||||
|
fields: { value: [] },
|
||||||
|
requiredFilled: { value: true },
|
||||||
|
saveAll: mockSaveAll,
|
||||||
|
refresh: mockRefreshCF,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePermissions (auto-imported in Nuxt)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.stubGlobal('usePermissions', () => ({
|
||||||
|
canEdit: { value: true },
|
||||||
|
canManage: { value: true },
|
||||||
|
isAdmin: { value: false },
|
||||||
|
isGranted: () => true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useEntityHistory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useEntityHistory', () => ({
|
||||||
|
useEntityHistory: () => ({
|
||||||
|
history: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
error: { value: null },
|
||||||
|
loadHistory: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — shared utils
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/structureDisplayUtils', () => ({
|
||||||
|
getStructurePieces: (s: any) => Array.isArray(s?.pieces) ? s.pieces : [],
|
||||||
|
getStructureProducts: (s: any) => Array.isArray(s?.products) ? s.products : [],
|
||||||
|
resolvePieceLabel: (p: any) => p?.name ?? '',
|
||||||
|
resolveProductLabel: (p: any) => p?.name ?? '',
|
||||||
|
resolveSubcomponentLabel: (p: any) => p?.name ?? '',
|
||||||
|
fetchModelTypeNames: vi.fn().mockResolvedValue({}),
|
||||||
|
buildTypeLabelMap: () => ({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/modelUtils', () => ({
|
||||||
|
formatStructurePreview: () => '',
|
||||||
|
normalizeStructureForEditor: (s: any) => s,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/constructeurUtils', () => ({
|
||||||
|
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
|
||||||
|
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/structureSelectionUtils', () => ({
|
||||||
|
collectStructureSelections: () => ({ pieces: [], products: [], components: [] }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/utils/documentPreview', () => ({
|
||||||
|
canPreviewDocument: () => false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test data — component with structure containing slots
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const COMPONENT_ID = 'cl-comp-1'
|
||||||
|
|
||||||
|
function buildComponentWithStructure() {
|
||||||
|
return {
|
||||||
|
...mockComponentFromApi,
|
||||||
|
id: COMPONENT_ID,
|
||||||
|
'@id': `/api/composants/${COMPONENT_ID}`,
|
||||||
|
description: 'Un moteur triphas\u00e9 haute performance',
|
||||||
|
prix: '1500.00',
|
||||||
|
typeComposantId: 'tc-moteur',
|
||||||
|
structure: {
|
||||||
|
pieces: [
|
||||||
|
{
|
||||||
|
slotId: 'ps-001',
|
||||||
|
typePieceId: 'tp-bearing-001',
|
||||||
|
selectedPieceId: 'piece-001',
|
||||||
|
selectedPieceName: 'Roulement 6205',
|
||||||
|
quantity: 2,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slotId: 'ps-002',
|
||||||
|
typePieceId: 'tp-seal-002',
|
||||||
|
selectedPieceId: 'piece-002',
|
||||||
|
selectedPieceName: 'Joint torique',
|
||||||
|
quantity: 1,
|
||||||
|
position: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
slotId: 'prs-001',
|
||||||
|
typeProductId: 'tprod-grease-001',
|
||||||
|
selectedProductId: 'prod-001',
|
||||||
|
selectedProductName: 'Graisse LGMT2',
|
||||||
|
familyCode: 'LUB',
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subcomponents: [
|
||||||
|
{
|
||||||
|
slotId: 'scs-001',
|
||||||
|
typeComposantId: 'tc-sub-001',
|
||||||
|
selectedComponentId: 'comp-sub-001',
|
||||||
|
selectedComponentName: 'Palier avant',
|
||||||
|
alias: 'Palier avant',
|
||||||
|
familyCode: 'PAL',
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Wait for next tick + micro-tasks so watchers fire. */
|
||||||
|
const tick = () => new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the composable AND hydrate it by resolving the mocked get.
|
||||||
|
* Returns the composable instance after fetch + watcher hydration.
|
||||||
|
*/
|
||||||
|
async function createAndHydrate(overrides?: Partial<ReturnType<typeof buildComponentWithStructure>>) {
|
||||||
|
const comp = { ...buildComponentWithStructure(), ...overrides }
|
||||||
|
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url.includes(`/composants/${COMPONENT_ID}`)) {
|
||||||
|
return Promise.resolve({ success: true, data: structuredClone(comp) })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true, data: wrapCollection([]) })
|
||||||
|
})
|
||||||
|
|
||||||
|
mockFetchLinks.mockResolvedValue([
|
||||||
|
{ ...mockLinkSKF },
|
||||||
|
])
|
||||||
|
|
||||||
|
const composable = useComponentEdit(COMPONENT_ID)
|
||||||
|
|
||||||
|
// fetchComponent is called, then the watcher hydrates editionForm
|
||||||
|
await composable.fetchComponent()
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
return composable
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// beforeEach
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockComponentTypes.value = [
|
||||||
|
{ id: 'tc-moteur', name: 'Moteur \u00e9lectrique', category: 'COMPONENT', structure: null },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// fetchComponent — hydration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('fetchComponent — hydration', () => {
|
||||||
|
it('loads simple fields into editionForm (name, reference, description, prix)', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(composable.editionForm.name).toBe('Moteur principal')
|
||||||
|
expect(composable.editionForm.reference).toBe('COMP-MOT-001')
|
||||||
|
expect(composable.editionForm.description).toBe('Un moteur triphas\u00e9 haute performance')
|
||||||
|
expect(composable.editionForm.prix).toBe('1500.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads component object with structure containing slots', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(composable.component.value).not.toBeNull()
|
||||||
|
expect(composable.component.value.structure).toBeDefined()
|
||||||
|
expect(composable.component.value.structure.pieces).toHaveLength(2)
|
||||||
|
expect(composable.component.value.structure.products).toHaveLength(1)
|
||||||
|
expect(composable.component.value.structure.subcomponents).toHaveLength(1)
|
||||||
|
expect(composable.component.value.customFieldValues).toBeDefined()
|
||||||
|
expect(Array.isArray(composable.component.value.customFieldValues)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads constructeur links via fetchLinks', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(mockFetchLinks).toHaveBeenCalledWith('composant', COMPONENT_ID)
|
||||||
|
expect(composable.constructeurLinks.value).toHaveLength(1)
|
||||||
|
expect(composable.constructeurLinks.value[0].constructeurId).toBe(mockConstructeurSKF.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slot operations — no data loss
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('slot operations — no data loss', () => {
|
||||||
|
it('setting piece slot selection preserves product and subcomponent slots', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Record initial product and subcomponent slot entries
|
||||||
|
const initialProductSlots = composable.productSlotEntries.value
|
||||||
|
const initialSubSlots = composable.subcomponentSlotEntries.value
|
||||||
|
|
||||||
|
expect(initialProductSlots).toHaveLength(1)
|
||||||
|
expect(initialSubSlots).toHaveLength(1)
|
||||||
|
|
||||||
|
// Change a piece slot selection
|
||||||
|
composable.setPieceSlotSelection('ps-001', 'piece-999')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Piece slot changed
|
||||||
|
const pieceSlots = composable.pieceSlotEntries.value
|
||||||
|
expect(pieceSlots.find(s => s.slotId === 'ps-001')?.selectedPieceId).toBe('piece-999')
|
||||||
|
|
||||||
|
// Product and subcomponent slots untouched
|
||||||
|
expect(composable.productSlotEntries.value).toHaveLength(1)
|
||||||
|
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-001')
|
||||||
|
expect(composable.subcomponentSlotEntries.value).toHaveLength(1)
|
||||||
|
expect(composable.subcomponentSlotEntries.value[0].selectedComponentId).toBe('comp-sub-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setting product slot selection preserves piece slots', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Change a product slot
|
||||||
|
composable.setProductSlotSelection('prs-001', 'prod-new-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Product changed
|
||||||
|
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-new-001')
|
||||||
|
|
||||||
|
// Piece slots untouched
|
||||||
|
expect(composable.pieceSlotEntries.value).toHaveLength(2)
|
||||||
|
expect(composable.pieceSlotEntries.value[0].selectedPieceId).toBe('piece-001')
|
||||||
|
expect(composable.pieceSlotEntries.value[1].selectedPieceId).toBe('piece-002')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setting subcomponent slot selection preserves piece and product slots', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Change a subcomponent slot
|
||||||
|
composable.setSubcomponentSlotSelection('scs-001', 'comp-new-sub')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Subcomponent changed
|
||||||
|
expect(composable.subcomponentSlotEntries.value[0].selectedComponentId).toBe('comp-new-sub')
|
||||||
|
|
||||||
|
// Piece and product slots untouched
|
||||||
|
expect(composable.pieceSlotEntries.value[0].selectedPieceId).toBe('piece-001')
|
||||||
|
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setting slot quantity preserves selectedPieceId', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Set a piece selection first
|
||||||
|
composable.setPieceSlotSelection('ps-001', 'piece-special')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Now change quantity on the same slot
|
||||||
|
composable.setSlotQuantity('ps-001', 5)
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
const slot = composable.pieceSlotEntries.value.find(s => s.slotId === 'ps-001')
|
||||||
|
expect(slot?.selectedPieceId).toBe('piece-special')
|
||||||
|
expect(slot?.quantity).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// submitEdition — no data loss
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitEdition — no data loss', () => {
|
||||||
|
it('sends all form fields in PATCH payload via updateComposant', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Modify form fields
|
||||||
|
composable.editionForm.name = 'Moteur modifi\u00e9'
|
||||||
|
composable.editionForm.description = 'Nouvelle description'
|
||||||
|
composable.editionForm.reference = 'REF-MOD-001'
|
||||||
|
composable.editionForm.prix = '2500'
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
name: 'Moteur modifi\u00e9',
|
||||||
|
description: 'Nouvelle description',
|
||||||
|
reference: 'REF-MOD-001',
|
||||||
|
prix: '2500',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves custom fields after patch', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('patches slot edits to correct endpoints', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockPatch.mockResolvedValue({ success: true, data: {} })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Make slot edits
|
||||||
|
composable.setPieceSlotSelection('ps-001', 'piece-new')
|
||||||
|
composable.setSlotQuantity('ps-002', 3)
|
||||||
|
composable.setProductSlotSelection('prs-001', 'prod-new')
|
||||||
|
composable.setSubcomponentSlotSelection('scs-001', 'comp-new')
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
// Verify piece slot patches
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-new' })
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-002', { quantity: 3 })
|
||||||
|
|
||||||
|
// Verify product slot patch
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composant-product-slots/prs-001', { selectedProductId: 'prod-new' })
|
||||||
|
|
||||||
|
// Verify subcomponent slot patch
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composant-subcomponent-slots/scs-001', { selectedComposantId: 'comp-new' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs constructeur links with diff', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Add a second constructeur link
|
||||||
|
composable.constructeurLinks.value = [
|
||||||
|
{ ...mockLinkSKF },
|
||||||
|
{ ...mockLinkFAG },
|
||||||
|
]
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
// originalConstructeurLinks was set to [mockLinkSKF] from fetchLinks
|
||||||
|
// formLinks is now [mockLinkSKF, mockLinkFAG]
|
||||||
|
const [entityType, entityId, origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
||||||
|
expect(entityType).toBe('composant')
|
||||||
|
expect(entityId).toBe(COMPONENT_ID)
|
||||||
|
expect(origLinks).toHaveLength(1)
|
||||||
|
expect(formLinks).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editing name does not lose constructeur links', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Only edit name
|
||||||
|
composable.editionForm.name = 'Nouveau nom moteur'
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
// updateComposant was called with name change
|
||||||
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.name).toBe('Nouveau nom moteur')
|
||||||
|
|
||||||
|
// syncLinks was still called (preserving links)
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
||||||
|
// Both should contain the original SKF link
|
||||||
|
expect(origLinks).toHaveLength(1)
|
||||||
|
expect(formLinks).toHaveLength(1)
|
||||||
|
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Document operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('document operations', () => {
|
||||||
|
it('populates componentDocuments from fetchComponent response', async () => {
|
||||||
|
const docFixtures = [
|
||||||
|
{ id: 'doc-1', name: 'photo.jpg', type: 'photo' },
|
||||||
|
{ id: 'doc-2', name: 'schema.pdf', type: 'schema' },
|
||||||
|
]
|
||||||
|
const composable = await createAndHydrate({ documents: docFixtures } as any)
|
||||||
|
|
||||||
|
expect(composable.componentDocuments.value).toHaveLength(2)
|
||||||
|
expect(composable.componentDocuments.value[0].id).toBe('doc-1')
|
||||||
|
expect(composable.componentDocuments.value[1].id).toBe('doc-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets componentDocuments to empty array when response has no documents', async () => {
|
||||||
|
const composable = await createAndHydrate({ documents: undefined } as any)
|
||||||
|
|
||||||
|
expect(composable.componentDocuments.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleFilesAdded calls uploadDocuments with composantId context', async () => {
|
||||||
|
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
|
||||||
|
mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: [] })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
const files = [new File(['content'], 'test.pdf', { type: 'application/pdf' })]
|
||||||
|
await composable.handleFilesAdded(files)
|
||||||
|
|
||||||
|
expect(mockUploadDocuments).toHaveBeenCalledTimes(1)
|
||||||
|
const callArgs = mockUploadDocuments.mock.calls[0]![0]
|
||||||
|
expect(callArgs.files).toBe(files)
|
||||||
|
expect(callArgs.context.composantId).toBe(COMPONENT_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleFilesAdded does nothing when files array is empty', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.handleFilesAdded([])
|
||||||
|
|
||||||
|
expect(mockUploadDocuments).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handleFilesAdded refreshes documents after successful upload', async () => {
|
||||||
|
const refreshedDocs = [{ id: 'doc-new', name: 'uploaded.pdf' }]
|
||||||
|
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
|
||||||
|
mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: refreshedDocs })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
const files = [new File(['data'], 'uploaded.pdf')]
|
||||||
|
await composable.handleFilesAdded(files)
|
||||||
|
|
||||||
|
expect(mockLoadDocumentsByComponent).toHaveBeenCalledWith(COMPONENT_ID, { updateStore: false })
|
||||||
|
expect(composable.componentDocuments.value).toHaveLength(1)
|
||||||
|
expect(composable.componentDocuments.value[0].id).toBe('doc-new')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeDocument calls deleteDocument and removes from local list', async () => {
|
||||||
|
mockDeleteDocument.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate({
|
||||||
|
documents: [
|
||||||
|
{ id: 'doc-a', name: 'a.pdf' },
|
||||||
|
{ id: 'doc-b', name: 'b.pdf' },
|
||||||
|
],
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(composable.componentDocuments.value).toHaveLength(2)
|
||||||
|
|
||||||
|
await composable.removeDocument('doc-a')
|
||||||
|
|
||||||
|
expect(mockDeleteDocument).toHaveBeenCalledWith('doc-a', { updateStore: false })
|
||||||
|
expect(composable.componentDocuments.value).toHaveLength(1)
|
||||||
|
expect(composable.componentDocuments.value[0].id).toBe('doc-b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeDocument does nothing when documentId is falsy', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.removeDocument(null)
|
||||||
|
await composable.removeDocument(undefined)
|
||||||
|
|
||||||
|
expect(mockDeleteDocument).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Null field handling in PATCH payload
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('null field handling in PATCH payload', () => {
|
||||||
|
it('empty prix string sends null in payload', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.prix = ''
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('whitespace-only prix string sends null in payload', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.prix = ' '
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valid prix string sends stringified number', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.prix = '42.50'
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBe('42.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty reference string sends null in payload', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.reference = ''
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.reference).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty description string sends null in payload', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.description = ''
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.description).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('whitespace-only description sends null in payload', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.description = ' '
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.description).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('name is trimmed but never null', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.name = ' Moteur '
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdateComposant.mock.calls[0]![1]
|
||||||
|
expect(payload.name).toBe('Moteur')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('error paths', () => {
|
||||||
|
it('does not save custom fields when updateComposant returns { success: false }', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: false })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.editionForm.name = 'Test'
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not patch slots when updateComposant returns { success: false }', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: false })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.setPieceSlotSelection('ps-001', 'piece-new')
|
||||||
|
composable.setProductSlotSelection('prs-001', 'prod-new')
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not sync constructeur links when updateComposant fails', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: false })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when saveAllCustomFields returns failed fields', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockSaveAll.mockResolvedValue(['Tension nominale', 'Indice de protection'])
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockShowError).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockShowError.mock.calls[0]![0]).toContain('Tension nominale')
|
||||||
|
expect(mockShowError.mock.calls[0]![0]).toContain('Indice de protection')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still saves slots and syncs links even when custom fields fail', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockSaveAll.mockResolvedValue(['Tension nominale'])
|
||||||
|
mockPatch.mockResolvedValue({ success: true, data: {} })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.setPieceSlotSelection('ps-001', 'piece-after-cf-fail')
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
// Slots still patched despite custom field failure
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-after-cf-fail' })
|
||||||
|
// Links still synced
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
// Success toast still shown (alongside the error toast for CF)
|
||||||
|
expect(mockShowSuccess).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when submitEdition throws', async () => {
|
||||||
|
mockUpdateComposant.mockRejectedValue(new Error('Network failure'))
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockShowError).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockShowError.mock.calls[0]![0]).toContain('Network failure')
|
||||||
|
expect(composable.saving.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets saving flag even when updateComposant throws', async () => {
|
||||||
|
mockUpdateComposant.mockRejectedValue(new Error('Server error'))
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(composable.saving.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom field save verification
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('custom field save verification', () => {
|
||||||
|
it('saveAllCustomFields is called after successful updateComposant', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockSaveAll.mockResolvedValue([])
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockSaveAll).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show error toast when saveAll returns empty array (no failures)', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockSaveAll.mockResolvedValue([])
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
// showError should NOT have been called (only showSuccess)
|
||||||
|
expect(mockShowError).not.toHaveBeenCalled()
|
||||||
|
expect(mockShowSuccess).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error with all failed field names joined', async () => {
|
||||||
|
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
|
||||||
|
mockSaveAll.mockResolvedValue(['Champ A', 'Champ B', 'Champ C'])
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockShowError).toHaveBeenCalledTimes(1)
|
||||||
|
const errorMsg = mockShowError.mock.calls[0]![0] as string
|
||||||
|
expect(errorMsg).toContain('Champ A')
|
||||||
|
expect(errorMsg).toContain('Champ B')
|
||||||
|
expect(errorMsg).toContain('Champ C')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitEdition does nothing when component is null', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Force component to null
|
||||||
|
composable.component.value = null
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdateComposant).not.toHaveBeenCalled()
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
157
frontend/tests/composables/useComposants.test.ts
Normal file
157
frontend/tests/composables/useComposants.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { useComposants } from '~/composables/useComposants'
|
||||||
|
import { mockComponentFromApi, wrapCollection } from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const { clearComposantsCache } = useComposants()
|
||||||
|
clearComposantsCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createComposant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('createComposant', () => {
|
||||||
|
it('sends all fields in creation payload', async () => {
|
||||||
|
const created = { ...mockComponentFromApi, id: 'comp-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createComposant } = useComposants()
|
||||||
|
const result = await createComposant({
|
||||||
|
name: 'Moteur principal',
|
||||||
|
reference: 'COMP-MOT-001',
|
||||||
|
description: 'Un moteur',
|
||||||
|
typeComposantId: 'tc-moteur',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
// normalizeRelationIds converts typeComposantId to typeComposant IRI
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/composants', expect.objectContaining({
|
||||||
|
name: 'Moteur principal',
|
||||||
|
reference: 'COMP-MOT-001',
|
||||||
|
description: 'Un moteur',
|
||||||
|
typeComposant: '/api/model_types/tc-moteur',
|
||||||
|
}))
|
||||||
|
// typeComposantId should be removed by normalizeRelationIds
|
||||||
|
const payload = mockPost.mock.calls[0]![1]
|
||||||
|
expect(payload).not.toHaveProperty('typeComposantId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds created composant to cache', async () => {
|
||||||
|
const created = { ...mockComponentFromApi, id: 'comp-new', name: 'Nouveau composant' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createComposant, composants, total } = useComposants()
|
||||||
|
|
||||||
|
expect(composants.value).toHaveLength(0)
|
||||||
|
expect(total.value).toBe(0)
|
||||||
|
|
||||||
|
await createComposant({ name: 'Nouveau composant' })
|
||||||
|
|
||||||
|
expect(composants.value).toHaveLength(1)
|
||||||
|
expect(composants.value[0]!.id).toBe('comp-new')
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// updateComposant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('updateComposant', () => {
|
||||||
|
it('patches and updates cache', async () => {
|
||||||
|
// Seed the cache with one composant
|
||||||
|
const original = { ...mockComponentFromApi, id: 'comp-001', name: 'Ancien nom' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: original })
|
||||||
|
const { createComposant, updateComposant, composants } = useComposants()
|
||||||
|
await createComposant({ name: 'Ancien nom' })
|
||||||
|
expect(composants.value).toHaveLength(1)
|
||||||
|
|
||||||
|
// Now update
|
||||||
|
const updated = { ...original, name: 'Nouveau nom' }
|
||||||
|
mockPatch.mockResolvedValue({ success: true, data: updated })
|
||||||
|
|
||||||
|
const result = await updateComposant('comp-001', { name: 'Nouveau nom' })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/composants/comp-001', expect.objectContaining({
|
||||||
|
name: 'Nouveau nom',
|
||||||
|
}))
|
||||||
|
expect(composants.value[0]!.name).toBe('Nouveau nom')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteComposant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('deleteComposant', () => {
|
||||||
|
it('removes composant from cache on success', async () => {
|
||||||
|
// Seed cache
|
||||||
|
const item = { ...mockComponentFromApi, id: 'comp-del', name: 'A supprimer' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: item })
|
||||||
|
const { createComposant, deleteComposant, composants, total } = useComposants()
|
||||||
|
await createComposant({ name: 'A supprimer' })
|
||||||
|
expect(composants.value).toHaveLength(1)
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
const result = await deleteComposant('comp-del')
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(composants.value).toHaveLength(0)
|
||||||
|
expect(total.value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps composant in cache on failure', async () => {
|
||||||
|
// Seed cache
|
||||||
|
const item = { ...mockComponentFromApi, id: 'comp-keep', name: 'Garder' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: item })
|
||||||
|
const { createComposant, deleteComposant, composants, total } = useComposants()
|
||||||
|
await createComposant({ name: 'Garder' })
|
||||||
|
expect(composants.value).toHaveLength(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
|
||||||
|
const result = await deleteComposant('comp-keep')
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(composants.value).toHaveLength(1)
|
||||||
|
expect(composants.value[0]!.id).toBe('comp-keep')
|
||||||
|
})
|
||||||
|
})
|
||||||
237
frontend/tests/composables/useConstructeurLinks.test.ts
Normal file
237
frontend/tests/composables/useConstructeurLinks.test.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
|
||||||
|
import {
|
||||||
|
mockLinkSKF,
|
||||||
|
mockLinkFAG,
|
||||||
|
mockConstructeurSKF,
|
||||||
|
mockConstructeurFAG,
|
||||||
|
wrapCollection,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// fetchLinks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('fetchLinks', () => {
|
||||||
|
it('returns parsed links with all properties for composant', async () => {
|
||||||
|
const apiLinks = [
|
||||||
|
{
|
||||||
|
id: mockLinkSKF.linkId,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: mockLinkSKF.supplierReference,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mockLinkFAG.linkId,
|
||||||
|
constructeur: mockConstructeurFAG,
|
||||||
|
supplierReference: mockLinkFAG.supplierReference,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection(apiLinks) })
|
||||||
|
|
||||||
|
const { fetchLinks } = useConstructeurLinks()
|
||||||
|
const result = await fetchLinks('composant', 'comp-001')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
linkId: mockLinkSKF.linkId,
|
||||||
|
constructeurId: mockConstructeurSKF.id,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: mockLinkSKF.supplierReference,
|
||||||
|
})
|
||||||
|
expect(result[1]).toEqual({
|
||||||
|
linkId: mockLinkFAG.linkId,
|
||||||
|
constructeurId: mockConstructeurFAG.id,
|
||||||
|
constructeur: mockConstructeurFAG,
|
||||||
|
supplierReference: mockLinkFAG.supplierReference,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns supplierReference as null when absent from API', async () => {
|
||||||
|
const apiLinks = [
|
||||||
|
{
|
||||||
|
id: 'link-no-ref',
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
// no supplierReference key
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection(apiLinks) })
|
||||||
|
|
||||||
|
const { fetchLinks } = useConstructeurLinks()
|
||||||
|
const result = await fetchLinks('composant', 'comp-001')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]!.supplierReference).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['machine', '/machine_constructeur_links?machine=/api/machines/m-001', 'm-001'],
|
||||||
|
['product', '/product_constructeur_links?product=/api/products/p-001', 'p-001'],
|
||||||
|
['piece', '/piece_constructeur_links?piece=/api/pieces/pc-001', 'pc-001'],
|
||||||
|
['composant', '/composant_constructeur_links?composant=/api/composants/c-001', 'c-001'],
|
||||||
|
] as const)('uses correct endpoint for %s', async (entityType, expectedUrl, entityId) => {
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection([]) })
|
||||||
|
|
||||||
|
const { fetchLinks } = useConstructeurLinks()
|
||||||
|
await fetchLinks(entityType, entityId)
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(expectedUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array on API failure', async () => {
|
||||||
|
mockGet.mockResolvedValue({ success: false, data: null })
|
||||||
|
|
||||||
|
const { fetchLinks } = useConstructeurLinks()
|
||||||
|
const result = await fetchLinks('composant', 'comp-001')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// syncLinks — 3-way diff
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('syncLinks', () => {
|
||||||
|
it('creates new links via POST', async () => {
|
||||||
|
mockPost.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [], [mockLinkSKF])
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
|
||||||
|
composant: '/api/composants/comp-001',
|
||||||
|
constructeur: `/api/constructeurs/${mockConstructeurSKF.id}`,
|
||||||
|
supplierReference: mockLinkSKF.supplierReference,
|
||||||
|
})
|
||||||
|
expect(mockDel).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes removed links via DELETE', async () => {
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [mockLinkSKF], [])
|
||||||
|
|
||||||
|
expect(mockDel).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('patches when supplierReference changes (value to new value)', async () => {
|
||||||
|
mockPatch.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const updatedLink = { ...mockLinkSKF, supplierReference: 'NEW-REF-999' }
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [mockLinkSKF], [updatedLink])
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`, {
|
||||||
|
supplierReference: 'NEW-REF-999',
|
||||||
|
})
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockDel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('patches when supplierReference changes from value to null', async () => {
|
||||||
|
mockPatch.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const updatedLink = { ...mockLinkSKF, supplierReference: null }
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [mockLinkSKF], [updatedLink])
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`, {
|
||||||
|
supplierReference: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when links are identical (no API calls)', async () => {
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [mockLinkSKF], [mockLinkSKF])
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockDel).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles add + delete in same operation', async () => {
|
||||||
|
mockPost.mockResolvedValue({ success: true })
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [mockLinkSKF], [mockLinkFAG])
|
||||||
|
|
||||||
|
expect(mockDel).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`)
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
|
||||||
|
composant: '/api/composants/comp-001',
|
||||||
|
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
|
||||||
|
supplierReference: mockLinkFAG.supplierReference,
|
||||||
|
})
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty original and empty form (no-op)', async () => {
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [], [])
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockDel).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends supplierReference as null when empty string', async () => {
|
||||||
|
mockPost.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const linkWithEmpty = { ...mockLinkFAG, supplierReference: '' }
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks('composant', 'comp-001', [], [linkWithEmpty])
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
|
||||||
|
composant: '/api/composants/comp-001',
|
||||||
|
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
|
||||||
|
supplierReference: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['machine', '/machine_constructeur_links', 'machine', '/api/machines/m-001', 'm-001'],
|
||||||
|
['product', '/product_constructeur_links', 'product', '/api/products/p-001', 'p-001'],
|
||||||
|
['piece', '/piece_constructeur_links', 'piece', '/api/pieces/pc-001', 'pc-001'],
|
||||||
|
['composant', '/composant_constructeur_links', 'composant', '/api/composants/c-001', 'c-001'],
|
||||||
|
] as const)('uses correct endpoint and entity IRI for %s', async (entityType, endpoint, key, entityIri, entityId) => {
|
||||||
|
mockPost.mockResolvedValue({ success: true })
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { syncLinks } = useConstructeurLinks()
|
||||||
|
await syncLinks(entityType, entityId, [mockLinkSKF], [mockLinkFAG])
|
||||||
|
|
||||||
|
expect(mockDel).toHaveBeenCalledWith(`${endpoint}/${mockLinkSKF.linkId}`)
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(endpoint, {
|
||||||
|
[key]: entityIri,
|
||||||
|
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
|
||||||
|
supplierReference: mockLinkFAG.supplierReference,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
475
frontend/tests/composables/useCustomFieldInputs.test.ts
Normal file
475
frontend/tests/composables/useCustomFieldInputs.test.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
|
||||||
|
import {
|
||||||
|
shouldPersist,
|
||||||
|
formatValueForSave,
|
||||||
|
} from '~/shared/utils/customFields'
|
||||||
|
import {
|
||||||
|
mockCustomFieldDefs,
|
||||||
|
mockCustomFieldValues,
|
||||||
|
mockMachineCustomFieldDefs,
|
||||||
|
mockMachineCustomFieldValues,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUpdateCustomFieldValue = vi.fn()
|
||||||
|
const mockUpsertCustomFieldValue = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFields', () => ({
|
||||||
|
useCustomFields: () => ({
|
||||||
|
updateCustomFieldValue: mockUpdateCustomFieldValue,
|
||||||
|
upsertCustomFieldValue: mockUpsertCustomFieldValue,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockUpdateCustomFieldValue.mockResolvedValue({ success: true })
|
||||||
|
mockUpsertCustomFieldValue.mockResolvedValue({ success: true, data: { id: 'new-cfv-id', customField: { id: 'new-cf-id' } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Field initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('field initialization', () => {
|
||||||
|
it('merges all definitions with their values (6 defs → 6 allFields, 5 standalone fields)', () => {
|
||||||
|
const { fields, allFields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(allFields.value).toHaveLength(6)
|
||||||
|
expect(fields.value).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for number type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const numberField = fields.value.find(f => f.name === 'Tension nominale')
|
||||||
|
expect(numberField?.value).toBe('220')
|
||||||
|
expect(numberField?.type).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for boolean type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'Certifié CE')
|
||||||
|
expect(boolField?.value).toBe('true')
|
||||||
|
expect(boolField?.type).toBe('boolean')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for select type with options', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Indice de protection')
|
||||||
|
expect(selectField?.value).toBe('IP65')
|
||||||
|
expect(selectField?.type).toBe('select')
|
||||||
|
expect(selectField?.options).toEqual(['IP54', 'IP55', 'IP65'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for date type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date de calibration')
|
||||||
|
expect(dateField?.value).toBe('2025-06-15')
|
||||||
|
expect(dateField?.type).toBe('date')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves value for text type', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const textField = fields.value.find(f => f.name === 'Remarques techniques')
|
||||||
|
expect(textField?.value).toBe('Roulement renforcé pour environnement humide')
|
||||||
|
expect(textField?.type).toBe('text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses defaultValue when no persisted value exists', () => {
|
||||||
|
// Pass empty values array so all fields use defaultValue
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const numberField = fields.value.find(f => f.name === 'Tension nominale')
|
||||||
|
expect(numberField?.value).toBe('220')
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'Certifié CE')
|
||||||
|
expect(boolField?.value).toBe('false')
|
||||||
|
|
||||||
|
// No defaultValue → empty string
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date de calibration')
|
||||||
|
expect(dateField?.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters machineContextOnly in standalone context (allFields=6, fields=5)', () => {
|
||||||
|
const { fields, allFields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(allFields.value).toHaveLength(6)
|
||||||
|
expect(fields.value).toHaveLength(5)
|
||||||
|
expect(fields.value.every(f => !f.machineContextOnly)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows only machineContextOnly in machine context (1 field)', () => {
|
||||||
|
const { fields } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'machine',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fields.value).toHaveLength(1)
|
||||||
|
expect(fields.value[0]?.name).toBe('Position sur machine')
|
||||||
|
expect(fields.value[0]?.machineContextOnly).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Boolean — the tricky case
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('boolean — the tricky case', () => {
|
||||||
|
it('saves "false" value via update (not ignored)', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'Certifié CE')!
|
||||||
|
boolField.value = 'false'
|
||||||
|
|
||||||
|
await update(boolField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists boolean "false" in saveAll (not skipped)', async () => {
|
||||||
|
// Only provide the boolean field def + value
|
||||||
|
const boolDef = mockCustomFieldDefs[1]!
|
||||||
|
const boolVal = { ...mockCustomFieldValues[1]!, value: 'false' }
|
||||||
|
|
||||||
|
const { fields, saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref([boolDef]),
|
||||||
|
values: ref([boolVal]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fields.value[0]?.value).toBe('false')
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Number zero
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('number zero', () => {
|
||||||
|
it('saves "0" value (not ignored)', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockMachineCustomFieldDefs),
|
||||||
|
values: ref(mockMachineCustomFieldValues),
|
||||||
|
entityType: 'machine',
|
||||||
|
entityId: ref('cl-machine-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const numField = fields.value.find(f => f.name === 'Puissance (kW)')!
|
||||||
|
expect(numField.value).toBe('0')
|
||||||
|
|
||||||
|
await update(numField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-003', { value: '0' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Text empty string
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('text empty string', () => {
|
||||||
|
it('shouldPersist returns false for empty trimmed string', () => {
|
||||||
|
const field = {
|
||||||
|
customFieldId: 'cf-1',
|
||||||
|
customFieldValueId: null,
|
||||||
|
name: 'Notes',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
value: ' ',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(shouldPersist(field)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists non-empty text value', () => {
|
||||||
|
const field = {
|
||||||
|
customFieldId: 'cf-1',
|
||||||
|
customFieldValueId: null,
|
||||||
|
name: 'Notes',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
value: 'some text',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(shouldPersist(field)).toBe(true)
|
||||||
|
expect(formatValueForSave(field)).toBe('some text')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Select
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('select', () => {
|
||||||
|
it('saves changed option value', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Indice de protection')!
|
||||||
|
selectField.value = 'IP55'
|
||||||
|
|
||||||
|
await update(selectField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-003', { value: 'IP55' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Date
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('date', () => {
|
||||||
|
it('saves date value in correct format', async () => {
|
||||||
|
const { fields, update } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date de calibration')!
|
||||||
|
dateField.value = '2026-01-20'
|
||||||
|
|
||||||
|
await update(dateField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-004', { value: '2026-01-20' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// saveAll isolation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('saveAll isolation', () => {
|
||||||
|
it('saves all fields independently without losing values', async () => {
|
||||||
|
const { fields, saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Modify one field
|
||||||
|
const numberField = fields.value.find(f => f.name === 'Tension nominale')!
|
||||||
|
numberField.value = '380'
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
|
||||||
|
// All persistable fields should have been saved
|
||||||
|
// 5 fields in standalone context, all have values
|
||||||
|
expect(mockUpdateCustomFieldValue.mock.calls.length).toBeGreaterThanOrEqual(4)
|
||||||
|
|
||||||
|
// The modified field should have the new value
|
||||||
|
const numberCall = mockUpdateCustomFieldValue.mock.calls.find(
|
||||||
|
(c: any[]) => c[0] === 'cfv-001',
|
||||||
|
)
|
||||||
|
expect(numberCall?.[1]).toEqual({ value: '380' })
|
||||||
|
|
||||||
|
// Another field should still have its original value
|
||||||
|
const boolCall = mockUpdateCustomFieldValue.mock.calls.find(
|
||||||
|
(c: any[]) => c[0] === 'cfv-002',
|
||||||
|
)
|
||||||
|
expect(boolCall?.[1]).toEqual({ value: 'true' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upserts new value when no customFieldValueId exists', async () => {
|
||||||
|
// Use defs without matching values — no customFieldValueId
|
||||||
|
const defs = [mockCustomFieldDefs[0]!]
|
||||||
|
|
||||||
|
const { saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
|
||||||
|
// Should use upsert since no customFieldValueId
|
||||||
|
expect(mockUpsertCustomFieldValue).toHaveBeenCalledWith(
|
||||||
|
'cf-def-001',
|
||||||
|
'composant',
|
||||||
|
'cl-comp-1',
|
||||||
|
'220',
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns failed field names on error', async () => {
|
||||||
|
mockUpdateCustomFieldValue.mockResolvedValueOnce({ success: false })
|
||||||
|
|
||||||
|
const defs = [mockCustomFieldDefs[0]!]
|
||||||
|
const vals = [mockCustomFieldValues[0]!]
|
||||||
|
|
||||||
|
const { saveAll } = useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref(vals),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual(['Tension nominale'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// requiredFilled validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('requiredFilled validation', () => {
|
||||||
|
it('returns true when required fields have values (including defaultValue)', () => {
|
||||||
|
const { requiredFilled } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref(mockCustomFieldValues),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
// "Tension nominale" is required and has value '220'
|
||||||
|
expect(requiredFilled.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns true when required field uses defaultValue', () => {
|
||||||
|
// No values provided — required field should use defaultValue '220'
|
||||||
|
const { requiredFilled } = useCustomFieldInputs({
|
||||||
|
definitions: ref(mockCustomFieldDefs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requiredFilled.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when required field has no value and no default', () => {
|
||||||
|
// Create a required field with no default and no value
|
||||||
|
const defs = [{
|
||||||
|
id: 'cf-required-no-default',
|
||||||
|
name: 'Required Field',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
}]
|
||||||
|
|
||||||
|
const { requiredFilled } = useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref([]),
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: ref('cl-comp-1'),
|
||||||
|
context: 'standalone',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requiredFilled.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
319
frontend/tests/composables/useDocuments.test.ts
Normal file
319
frontend/tests/composables/useDocuments.test.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { wrapCollection } from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import under test (AFTER all vi.mock calls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — API layer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockPostFormData = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: vi.fn(),
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: mockPostFormData,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — Toast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockShowSuccess = vi.fn()
|
||||||
|
const mockShowError = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: mockShowSuccess,
|
||||||
|
showError: mockShowError,
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockDocument = {
|
||||||
|
id: 'doc-001',
|
||||||
|
name: 'photo.jpg',
|
||||||
|
filename: 'photo.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
size: 12345,
|
||||||
|
fileUrl: '/files/photo.jpg',
|
||||||
|
downloadUrl: '/files/photo.jpg/download',
|
||||||
|
createdAt: '2025-01-10T08:00:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockDocument2 = {
|
||||||
|
id: 'doc-002',
|
||||||
|
name: 'schema.pdf',
|
||||||
|
filename: 'schema.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
size: 54321,
|
||||||
|
fileUrl: '/files/schema.pdf',
|
||||||
|
downloadUrl: '/files/schema.pdf/download',
|
||||||
|
createdAt: '2025-01-11T09:00:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createMockFile(name: string, type = 'image/jpeg'): File {
|
||||||
|
return new File(['content'], name, { type })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// beforeEach
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// uploadDocuments — FormData is built correctly
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('uploadDocuments', () => {
|
||||||
|
it('builds FormData with file and context fields', async () => {
|
||||||
|
mockPostFormData.mockResolvedValue({ success: true, data: mockDocument })
|
||||||
|
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const file = createMockFile('photo.jpg')
|
||||||
|
|
||||||
|
await uploadDocuments({
|
||||||
|
files: [file],
|
||||||
|
context: { pieceId: 'piece-001', composantId: 'comp-001' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPostFormData).toHaveBeenCalledTimes(1)
|
||||||
|
const [endpoint, formData] = mockPostFormData.mock.calls[0]!
|
||||||
|
expect(endpoint).toBe('/documents')
|
||||||
|
|
||||||
|
expect(formData).toBeInstanceOf(FormData)
|
||||||
|
expect(formData.get('file')).toBe(file)
|
||||||
|
expect(formData.get('name')).toBe('photo.jpg')
|
||||||
|
expect(formData.get('pieceId')).toBe('piece-001')
|
||||||
|
expect(formData.get('composantId')).toBe('comp-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads multiple files separately', async () => {
|
||||||
|
mockPostFormData
|
||||||
|
.mockResolvedValueOnce({ success: true, data: mockDocument })
|
||||||
|
.mockResolvedValueOnce({ success: true, data: mockDocument2 })
|
||||||
|
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const file1 = createMockFile('photo.jpg')
|
||||||
|
const file2 = createMockFile('schema.pdf', 'application/pdf')
|
||||||
|
|
||||||
|
const result = await uploadDocuments({
|
||||||
|
files: [file1, file2],
|
||||||
|
context: { machineId: 'machine-001' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPostFormData).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
// First call
|
||||||
|
const [, formData1] = mockPostFormData.mock.calls[0]!
|
||||||
|
expect(formData1.get('name')).toBe('photo.jpg')
|
||||||
|
expect(formData1.get('machineId')).toBe('machine-001')
|
||||||
|
|
||||||
|
// Second call
|
||||||
|
const [, formData2] = mockPostFormData.mock.calls[1]!
|
||||||
|
expect(formData2.get('name')).toBe('schema.pdf')
|
||||||
|
expect(formData2.get('machineId')).toBe('machine-001')
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(Array.isArray(result.data) ? result.data : []).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends type to FormData when provided in context', async () => {
|
||||||
|
mockPostFormData.mockResolvedValue({ success: true, data: mockDocument })
|
||||||
|
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const file = createMockFile('facture.pdf', 'application/pdf')
|
||||||
|
|
||||||
|
await uploadDocuments({
|
||||||
|
files: [file],
|
||||||
|
context: { siteId: 'site-001', type: 'facture' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const [, formData] = mockPostFormData.mock.calls[0]!
|
||||||
|
expect(formData.get('type')).toBe('facture')
|
||||||
|
expect(formData.get('siteId')).toBe('site-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when no files provided', async () => {
|
||||||
|
const { uploadDocuments } = useDocuments()
|
||||||
|
|
||||||
|
const result = await uploadDocuments({ files: [], context: {} })
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(mockPostFormData).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadDocumentsByComponent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('loadDocumentsByComponent', () => {
|
||||||
|
it('calls correct endpoint /documents/composant/{id}', async () => {
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
|
||||||
|
|
||||||
|
const { loadDocumentsByComponent } = useDocuments()
|
||||||
|
const result = await loadDocumentsByComponent('comp-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/documents/composant/comp-001')
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for empty componentId', async () => {
|
||||||
|
const { loadDocumentsByComponent } = useDocuments()
|
||||||
|
const result = await loadDocumentsByComponent('')
|
||||||
|
|
||||||
|
expect(mockGet).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadDocumentsByPiece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('loadDocumentsByPiece', () => {
|
||||||
|
it('calls correct endpoint /documents/piece/{id}', async () => {
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
|
||||||
|
|
||||||
|
const { loadDocumentsByPiece } = useDocuments()
|
||||||
|
const result = await loadDocumentsByPiece('piece-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/documents/piece/piece-001')
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for empty pieceId', async () => {
|
||||||
|
const { loadDocumentsByPiece } = useDocuments()
|
||||||
|
const result = await loadDocumentsByPiece('')
|
||||||
|
|
||||||
|
expect(mockGet).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadDocumentsByMachine
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('loadDocumentsByMachine', () => {
|
||||||
|
it('calls correct endpoint /documents/machine/{id}', async () => {
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
|
||||||
|
|
||||||
|
const { loadDocumentsByMachine } = useDocuments()
|
||||||
|
const result = await loadDocumentsByMachine('machine-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/documents/machine/machine-001')
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for empty machineId', async () => {
|
||||||
|
const { loadDocumentsByMachine } = useDocuments()
|
||||||
|
const result = await loadDocumentsByMachine('')
|
||||||
|
|
||||||
|
expect(mockGet).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadDocumentsByProduct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('loadDocumentsByProduct', () => {
|
||||||
|
it('calls correct endpoint /documents/product/{id}', async () => {
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
|
||||||
|
|
||||||
|
const { loadDocumentsByProduct } = useDocuments()
|
||||||
|
const result = await loadDocumentsByProduct('prod-001')
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/documents/product/prod-001')
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error for empty productId', async () => {
|
||||||
|
const { loadDocumentsByProduct } = useDocuments()
|
||||||
|
const result = await loadDocumentsByProduct('')
|
||||||
|
|
||||||
|
expect(mockGet).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteDocument
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('deleteDocument', () => {
|
||||||
|
it('calls DELETE on correct endpoint', async () => {
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { deleteDocument } = useDocuments()
|
||||||
|
const result = await deleteDocument('doc-001')
|
||||||
|
|
||||||
|
expect(mockDel).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockDel).toHaveBeenCalledWith('/documents/doc-001')
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes from store when updateStore is true', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: wrapCollection([mockDocument, mockDocument2]),
|
||||||
|
})
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { loadDocuments, deleteDocument, documents } = useDocuments()
|
||||||
|
|
||||||
|
// Load documents into store first
|
||||||
|
await loadDocuments({ force: true })
|
||||||
|
|
||||||
|
expect(documents.value).toHaveLength(2)
|
||||||
|
|
||||||
|
// Delete with updateStore: true
|
||||||
|
await deleteDocument('doc-001', { updateStore: true })
|
||||||
|
|
||||||
|
expect(documents.value).toHaveLength(1)
|
||||||
|
expect(documents.value[0]!.id).toBe('doc-002')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success toast on successful delete', async () => {
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
|
||||||
|
const { deleteDocument } = useDocuments()
|
||||||
|
await deleteDocument('doc-001')
|
||||||
|
|
||||||
|
expect(mockShowSuccess).toHaveBeenCalledWith('Document supprimé')
|
||||||
|
})
|
||||||
|
})
|
||||||
267
frontend/tests/composables/useMachineDetailCustomFields.test.ts
Normal file
267
frontend/tests/composables/useMachineDetailCustomFields.test.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
|
||||||
|
import {
|
||||||
|
mockMachineCustomFieldDefs,
|
||||||
|
mockMachineCustomFieldValues,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUpdateCustomFieldValue = vi.fn()
|
||||||
|
const mockUpsertCustomFieldValue = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFields', () => ({
|
||||||
|
useCustomFields: () => ({
|
||||||
|
updateCustomFieldValue: mockUpdateCustomFieldValue,
|
||||||
|
upsertCustomFieldValue: mockUpsertCustomFieldValue,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockUpdateCustomFieldValue.mockResolvedValue({ success: true })
|
||||||
|
mockUpsertCustomFieldValue.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
data: { id: 'new-mcfv-id', customField: { id: 'new-mcf-id' } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper — create composable with machine context (no context filter)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createMachineFields(
|
||||||
|
defs = mockMachineCustomFieldDefs,
|
||||||
|
vals = mockMachineCustomFieldValues,
|
||||||
|
entityId = 'cl-machine-1',
|
||||||
|
) {
|
||||||
|
return useCustomFieldInputs({
|
||||||
|
definitions: ref(defs),
|
||||||
|
values: ref(vals),
|
||||||
|
entityType: 'machine',
|
||||||
|
entityId: ref(entityId),
|
||||||
|
// No context — machine custom fields don't use machineContextOnly filtering
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Machine custom field initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('machine custom field initialization', () => {
|
||||||
|
it('loads all machine custom fields with values (5 fields)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
expect(fields.value).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves text value (Numéro de série)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const textField = fields.value.find(f => f.name === 'Numéro de série')
|
||||||
|
expect(textField?.value).toBe('SN-2025-001234')
|
||||||
|
expect(textField?.type).toBe('text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves boolean value (En service = true)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'En service')
|
||||||
|
expect(boolField?.value).toBe('true')
|
||||||
|
expect(boolField?.type).toBe('boolean')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves number zero value (Puissance kW = 0)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const numField = fields.value.find(f => f.name === 'Puissance (kW)')
|
||||||
|
expect(numField?.value).toBe('0')
|
||||||
|
expect(numField?.type).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves select value (Catégorie ATEX = Zone 1)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')
|
||||||
|
expect(selectField?.value).toBe('Zone 1')
|
||||||
|
expect(selectField?.type).toBe('select')
|
||||||
|
expect(selectField?.options).toEqual(['Zone 0', 'Zone 1', 'Zone 2', 'Non classé'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves date value (Date mise en service = 2025-01-15)', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const dateField = fields.value.find(f => f.name === 'Date mise en service')
|
||||||
|
expect(dateField?.value).toBe('2025-01-15')
|
||||||
|
expect(dateField?.type).toBe('date')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Boolean checkbox — the critical test
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('boolean checkbox — the critical test', () => {
|
||||||
|
it('toggle true to false sends "false" (not deleted) via update()', async () => {
|
||||||
|
const { fields, update } = createMachineFields()
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'En service')!
|
||||||
|
expect(boolField.value).toBe('true')
|
||||||
|
|
||||||
|
// Toggle to false
|
||||||
|
boolField.value = 'false'
|
||||||
|
await update(boolField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'false' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggle false to true sends "true"', async () => {
|
||||||
|
// Start with boolean value = false
|
||||||
|
const falseVal = { ...mockMachineCustomFieldValues[1]!, value: 'false' }
|
||||||
|
const vals = mockMachineCustomFieldValues.map((v, i) => (i === 1 ? falseVal : v))
|
||||||
|
|
||||||
|
const { fields, update } = createMachineFields(mockMachineCustomFieldDefs, vals)
|
||||||
|
|
||||||
|
const boolField = fields.value.find(f => f.name === 'En service')!
|
||||||
|
expect(boolField.value).toBe('false')
|
||||||
|
|
||||||
|
// Toggle to true
|
||||||
|
boolField.value = 'true'
|
||||||
|
await update(boolField)
|
||||||
|
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'true' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boolean false is persisted in saveAll (not skipped)', async () => {
|
||||||
|
// Only the boolean field with value "false"
|
||||||
|
const boolDef = mockMachineCustomFieldDefs[1]!
|
||||||
|
const boolVal = { ...mockMachineCustomFieldValues[1]!, value: 'false' }
|
||||||
|
|
||||||
|
const { fields, saveAll } = createMachineFields([boolDef], [boolVal])
|
||||||
|
|
||||||
|
expect(fields.value[0]?.value).toBe('false')
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'false' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Number zero — not lost
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('number zero — not lost', () => {
|
||||||
|
it('preserves zero value after load', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const numField = fields.value.find(f => f.name === 'Puissance (kW)')!
|
||||||
|
expect(numField.value).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves zero value (not skipped) in saveAll', async () => {
|
||||||
|
// Only the number field with value "0"
|
||||||
|
const numDef = mockMachineCustomFieldDefs[2]!
|
||||||
|
const numVal = mockMachineCustomFieldValues[2]!
|
||||||
|
|
||||||
|
const { saveAll } = createMachineFields([numDef], [numVal])
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
expect(failed).toEqual([])
|
||||||
|
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-003', { value: '0' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Select field
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('select field', () => {
|
||||||
|
it('preserves selected option', () => {
|
||||||
|
const { fields } = createMachineFields()
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')!
|
||||||
|
expect(selectField.value).toBe('Zone 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses defaultValue when no value exists', () => {
|
||||||
|
// Use defs with a select that has a defaultValue
|
||||||
|
const defsWithDefault = mockMachineCustomFieldDefs.map((d, i) =>
|
||||||
|
i === 3 ? { ...d, defaultValue: 'Non classé' } : d,
|
||||||
|
)
|
||||||
|
|
||||||
|
// No values for the select field
|
||||||
|
const valsWithoutSelect = mockMachineCustomFieldValues.filter(
|
||||||
|
v => v.customField.name !== 'Catégorie ATEX',
|
||||||
|
)
|
||||||
|
|
||||||
|
const { fields } = createMachineFields(defsWithDefault, valsWithoutSelect)
|
||||||
|
|
||||||
|
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')!
|
||||||
|
expect(selectField.value).toBe('Non classé')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Field isolation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('field isolation', () => {
|
||||||
|
it('updating one field does not change other field values', async () => {
|
||||||
|
const { fields, update } = createMachineFields()
|
||||||
|
|
||||||
|
// Snapshot original values
|
||||||
|
const originalValues = fields.value.map(f => ({ name: f.name, value: f.value }))
|
||||||
|
|
||||||
|
// Update only the text field
|
||||||
|
const textField = fields.value.find(f => f.name === 'Numéro de série')!
|
||||||
|
textField.value = 'SN-UPDATED-999'
|
||||||
|
await update(textField)
|
||||||
|
|
||||||
|
// All other fields should still have their original values
|
||||||
|
for (const field of fields.value) {
|
||||||
|
if (field.name === 'Numéro de série') continue
|
||||||
|
const original = originalValues.find(o => o.name === field.name)
|
||||||
|
expect(field.value).toBe(original?.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saveAll preserves all field values even on partial failure', async () => {
|
||||||
|
// Make the second call fail (boolean field)
|
||||||
|
mockUpdateCustomFieldValue
|
||||||
|
.mockResolvedValueOnce({ success: true }) // text — Numéro de série
|
||||||
|
.mockResolvedValueOnce({ success: false }) // boolean — En service
|
||||||
|
.mockResolvedValue({ success: true }) // rest succeed
|
||||||
|
|
||||||
|
const { fields, saveAll } = createMachineFields()
|
||||||
|
|
||||||
|
// Snapshot values before saveAll
|
||||||
|
const valuesBefore = fields.value.map(f => ({ name: f.name, value: f.value }))
|
||||||
|
|
||||||
|
const failed = await saveAll()
|
||||||
|
|
||||||
|
// Only the boolean field should have failed
|
||||||
|
expect(failed).toEqual(['En service'])
|
||||||
|
|
||||||
|
// All field values should still be intact (not cleared or corrupted)
|
||||||
|
for (const field of fields.value) {
|
||||||
|
const before = valuesBefore.find(v => v.name === field.name)
|
||||||
|
expect(field.value).toBe(before?.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
700
frontend/tests/composables/useMachineDetailData.test.ts
Normal file
700
frontend/tests/composables/useMachineDetailData.test.ts
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import under test (after mocks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useMachineDetailData } from '~/composables/useMachineDetailData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock data — realistic /machines/{id}/structure response
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MACHINE_ID = 'cl-machine-abc123'
|
||||||
|
const SITE_ID = 'cl-site-nord-001'
|
||||||
|
const COMPONENT_LINK_ID = 'cl-mcl-001'
|
||||||
|
const PIECE_LINK_ID = 'cl-mpl-001'
|
||||||
|
const PRODUCT_LINK_ID = 'cl-mprl-001'
|
||||||
|
const COMPOSANT_ID = 'cl-comp-moteur-001'
|
||||||
|
const PIECE_ID = 'cl-piece-roul-001'
|
||||||
|
const PRODUCT_ID = 'cl-prod-graisse-001'
|
||||||
|
const CONSTRUCTEUR_ID = 'cstr-skf-001'
|
||||||
|
|
||||||
|
const mockConstructeurSKF = {
|
||||||
|
id: CONSTRUCTEUR_ID,
|
||||||
|
name: 'SKF',
|
||||||
|
email: 'contact@skf.com',
|
||||||
|
phone: '+33 1 23 45 67 89',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockStructureResponse = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
machine: {
|
||||||
|
id: MACHINE_ID,
|
||||||
|
name: 'Presse hydraulique PH-200',
|
||||||
|
reference: 'MACH-PH-200',
|
||||||
|
prix: 150000,
|
||||||
|
siteId: SITE_ID,
|
||||||
|
site: { id: SITE_ID, name: 'Usine Nord' },
|
||||||
|
documents: [{ id: 'doc-001', name: 'Manuel PH-200.pdf', type: 'manual' }],
|
||||||
|
customFieldValues: [
|
||||||
|
{
|
||||||
|
id: 'mcfv-001',
|
||||||
|
value: 'SN-2025-PH200',
|
||||||
|
customField: {
|
||||||
|
id: 'mcf-001',
|
||||||
|
name: 'Serial Number',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFields: [
|
||||||
|
{
|
||||||
|
id: 'mcf-001',
|
||||||
|
name: 'Serial Number',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
constructeurs: [
|
||||||
|
{
|
||||||
|
id: 'cl-mconst-001',
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: 'SKF-PH200',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
componentLinks: [
|
||||||
|
{
|
||||||
|
id: COMPONENT_LINK_ID,
|
||||||
|
composant: {
|
||||||
|
id: COMPOSANT_ID,
|
||||||
|
name: 'Moteur principal',
|
||||||
|
reference: 'COMP-MOT-001',
|
||||||
|
prix: 12500,
|
||||||
|
typeComposant: { id: 'tc-moteur', name: 'Moteur electrique' },
|
||||||
|
constructeurs: [mockConstructeurSKF],
|
||||||
|
constructeurIds: [CONSTRUCTEUR_ID],
|
||||||
|
documents: [],
|
||||||
|
customFields: [
|
||||||
|
{
|
||||||
|
definitionId: 'cf-comp-001',
|
||||||
|
name: 'Tension nominale',
|
||||||
|
type: 'number',
|
||||||
|
value: '380',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFieldValues: [],
|
||||||
|
},
|
||||||
|
overrides: {
|
||||||
|
name: 'Moteur principal PH-200',
|
||||||
|
reference: 'COMP-MOT-PH200',
|
||||||
|
prix: 13000,
|
||||||
|
},
|
||||||
|
contextCustomFields: [
|
||||||
|
{
|
||||||
|
id: 'ctx-cf-001',
|
||||||
|
name: 'Position sur machine',
|
||||||
|
type: 'text',
|
||||||
|
machineContextOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contextCustomFieldValues: [
|
||||||
|
{
|
||||||
|
id: 'ctx-cfv-001',
|
||||||
|
value: 'Bloc moteur gauche',
|
||||||
|
customField: {
|
||||||
|
id: 'ctx-cf-001',
|
||||||
|
name: 'Position sur machine',
|
||||||
|
type: 'text',
|
||||||
|
machineContextOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pieceLinks: [
|
||||||
|
{
|
||||||
|
id: PIECE_LINK_ID,
|
||||||
|
piece: {
|
||||||
|
id: PIECE_ID,
|
||||||
|
name: 'Roulement 6205',
|
||||||
|
reference: 'ROUL-6205',
|
||||||
|
prix: 45.90,
|
||||||
|
typePiece: { id: 'tp-bearing', name: 'Roulement' },
|
||||||
|
constructeurs: [mockConstructeurSKF],
|
||||||
|
documents: [],
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
|
overrides: {
|
||||||
|
name: 'Roulement 6205-RS',
|
||||||
|
},
|
||||||
|
quantity: 2,
|
||||||
|
parentComponentLinkId: COMPONENT_LINK_ID,
|
||||||
|
contextCustomFields: [],
|
||||||
|
contextCustomFieldValues: [
|
||||||
|
{
|
||||||
|
id: 'ctx-cfv-piece-001',
|
||||||
|
value: 'Cote entrainement',
|
||||||
|
customField: {
|
||||||
|
id: 'ctx-cf-piece-001',
|
||||||
|
name: 'Emplacement',
|
||||||
|
type: 'text',
|
||||||
|
machineContextOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
childLinks: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pieceLinks: [],
|
||||||
|
productLinks: [
|
||||||
|
{
|
||||||
|
id: PRODUCT_LINK_ID,
|
||||||
|
product: {
|
||||||
|
id: PRODUCT_ID,
|
||||||
|
name: 'Graisse LGMT2',
|
||||||
|
reference: 'LUB-LGMT2',
|
||||||
|
prix: 45.90,
|
||||||
|
},
|
||||||
|
overrides: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response with NO overrides — for fallback testing
|
||||||
|
const mockStructureNoOverrides = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
machine: {
|
||||||
|
...mockStructureResponse.data.machine,
|
||||||
|
},
|
||||||
|
componentLinks: [
|
||||||
|
{
|
||||||
|
id: COMPONENT_LINK_ID,
|
||||||
|
composant: {
|
||||||
|
id: COMPOSANT_ID,
|
||||||
|
name: 'Moteur principal',
|
||||||
|
reference: 'COMP-MOT-001',
|
||||||
|
prix: 12500,
|
||||||
|
typeComposant: { id: 'tc-moteur', name: 'Moteur electrique' },
|
||||||
|
constructeurs: [],
|
||||||
|
documents: [],
|
||||||
|
customFields: [],
|
||||||
|
customFieldValues: [],
|
||||||
|
},
|
||||||
|
overrides: null,
|
||||||
|
contextCustomFields: [],
|
||||||
|
contextCustomFieldValues: [],
|
||||||
|
pieceLinks: [
|
||||||
|
{
|
||||||
|
id: PIECE_LINK_ID,
|
||||||
|
piece: {
|
||||||
|
id: PIECE_ID,
|
||||||
|
name: 'Roulement 6205',
|
||||||
|
reference: 'ROUL-6205',
|
||||||
|
prix: 45.90,
|
||||||
|
typePiece: { id: 'tp-bearing', name: 'Roulement' },
|
||||||
|
constructeurs: [],
|
||||||
|
documents: [],
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
|
overrides: null,
|
||||||
|
quantity: 1,
|
||||||
|
parentComponentLinkId: COMPONENT_LINK_ID,
|
||||||
|
contextCustomFields: [],
|
||||||
|
contextCustomFieldValues: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
childLinks: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pieceLinks: [],
|
||||||
|
productLinks: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — all composables used by useMachineDetailData
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
patch: mockPatch,
|
||||||
|
post: mockPost,
|
||||||
|
delete: mockDel,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useMachines', () => ({
|
||||||
|
useMachines: () => ({
|
||||||
|
updateMachine: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
updateStructure: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComposants', () => ({
|
||||||
|
useComposants: () => ({
|
||||||
|
updateComposant: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieces', () => ({
|
||||||
|
usePieces: () => ({
|
||||||
|
updatePiece: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useComponentTypes', () => ({
|
||||||
|
useComponentTypes: () => ({
|
||||||
|
componentTypes: ref([
|
||||||
|
{ id: 'tc-moteur', name: 'Moteur electrique' },
|
||||||
|
]),
|
||||||
|
loadComponentTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieceTypes', () => ({
|
||||||
|
usePieceTypes: () => ({
|
||||||
|
pieceTypes: ref([
|
||||||
|
{ id: 'tp-bearing', name: 'Roulement' },
|
||||||
|
]),
|
||||||
|
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFields', () => ({
|
||||||
|
useCustomFields: () => ({
|
||||||
|
upsertCustomFieldValue: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
updateCustomFieldValue: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
constructeurs: ref([mockConstructeurSKF]),
|
||||||
|
loadConstructeurs: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useSites', () => ({
|
||||||
|
useSites: () => ({
|
||||||
|
sites: ref([{ id: SITE_ID, name: 'Usine Nord' }]),
|
||||||
|
loadSites: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useProducts', () => ({
|
||||||
|
useProducts: () => ({
|
||||||
|
products: ref([
|
||||||
|
{ id: PRODUCT_ID, name: 'Graisse LGMT2', reference: 'LUB-LGMT2', prix: 45.90 },
|
||||||
|
]),
|
||||||
|
loadProducts: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useDocuments', () => ({
|
||||||
|
useDocuments: () => ({
|
||||||
|
uploadDocuments: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
deleteDocument: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
loadDocumentsByMachine: vi.fn().mockResolvedValue({ success: true, data: [] }),
|
||||||
|
loadDocumentsByProduct: vi.fn().mockResolvedValue({ success: true, data: [] }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurLinks', () => ({
|
||||||
|
useConstructeurLinks: () => ({
|
||||||
|
fetchLinks: vi.fn().mockResolvedValue([]),
|
||||||
|
syncLinks: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/utils/printTemplates/machineReport', () => ({
|
||||||
|
buildMachinePrintContext: vi.fn(),
|
||||||
|
buildMachinePrintHtml: vi.fn().mockReturnValue('<html></html>'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/utils/documentPreview', () => ({
|
||||||
|
canPreviewDocument: vi.fn().mockReturnValue(false),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/utils/documentDisplayUtils', () => ({
|
||||||
|
downloadDocument: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockGet.mockResolvedValue(mockStructureResponse)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadAndReturn(responseOverride?: unknown) {
|
||||||
|
if (responseOverride) {
|
||||||
|
mockGet.mockResolvedValue(responseOverride)
|
||||||
|
}
|
||||||
|
const result = useMachineDetailData(MACHINE_ID)
|
||||||
|
await result.loadMachineData()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 1. Hierarchy loading
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('hierarchy loading', () => {
|
||||||
|
it('loads machine with all core fields', async () => {
|
||||||
|
const { machine, machineName, machineReference, machineSiteId } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machine.value).not.toBeNull()
|
||||||
|
expect(machine.value!.id).toBe(MACHINE_ID)
|
||||||
|
expect(machine.value!.name).toBe('Presse hydraulique PH-200')
|
||||||
|
expect(machine.value!.reference).toBe('MACH-PH-200')
|
||||||
|
expect(machine.value!.prix).toBe(150000)
|
||||||
|
expect(machineName.value).toBe('Presse hydraulique PH-200')
|
||||||
|
expect(machineReference.value).toBe('MACH-PH-200')
|
||||||
|
expect(machineSiteId.value).toBe(SITE_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls GET /machines/{id}/structure', async () => {
|
||||||
|
await loadAndReturn()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(`/machines/${MACHINE_ID}/structure`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads componentLinks from structure response', async () => {
|
||||||
|
const { machineComponentLinks } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machineComponentLinks.value).toHaveLength(1)
|
||||||
|
expect(machineComponentLinks.value[0]!.id).toBe(COMPONENT_LINK_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds component hierarchy with composant data', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(components.value.length).toBeGreaterThanOrEqual(1)
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.composantId).toBe(COMPOSANT_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads piece links nested under their parent componentLink', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
const pieces = comp.pieces as Record<string, unknown>[]
|
||||||
|
expect(pieces).toBeDefined()
|
||||||
|
expect(pieces.length).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
const piece = pieces[0]!
|
||||||
|
expect(piece.pieceId).toBe(PIECE_ID)
|
||||||
|
expect(piece.parentComponentLinkId).toBe(COMPONENT_LINK_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves piece quantity', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
const pieces = comp.pieces as Record<string, unknown>[]
|
||||||
|
const piece = pieces[0]!
|
||||||
|
expect(piece.quantity).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads product links at machine level', async () => {
|
||||||
|
const { machineProductLinks } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machineProductLinks.value).toHaveLength(1)
|
||||||
|
expect(machineProductLinks.value[0]!.id).toBe(PRODUCT_LINK_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves machine documents', async () => {
|
||||||
|
const { machine } = await loadAndReturn()
|
||||||
|
|
||||||
|
const docs = machine.value!.documents as unknown[]
|
||||||
|
expect(docs).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves machine customFieldValues', async () => {
|
||||||
|
const { machine } = await loadAndReturn()
|
||||||
|
|
||||||
|
const cfv = machine.value!.customFieldValues as Record<string, unknown>[]
|
||||||
|
expect(cfv).toHaveLength(1)
|
||||||
|
expect((cfv[0] as any).value).toBe('SN-2025-PH200')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets loading to false after data load', async () => {
|
||||||
|
const { loading } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles failed API response gracefully', async () => {
|
||||||
|
const { machine, components, pieces } = await loadAndReturn({
|
||||||
|
success: false,
|
||||||
|
error: 'Not found',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(machine.value).toBeNull()
|
||||||
|
expect(components.value).toEqual([])
|
||||||
|
expect(pieces.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles invalid machine payload gracefully', async () => {
|
||||||
|
const { machine } = await loadAndReturn({
|
||||||
|
success: true,
|
||||||
|
data: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(machine.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 2. Overrides
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('overrides on component links', () => {
|
||||||
|
it('uses nameOverride when present', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.name).toBe('Moteur principal PH-200')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to composant.name when nameOverride is null', async () => {
|
||||||
|
const { components } = await loadAndReturn(mockStructureNoOverrides)
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.name).toBe('Moteur principal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses referenceOverride when present', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.reference).toBe('COMP-MOT-PH200')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to composant.reference when referenceOverride is null', async () => {
|
||||||
|
const { components } = await loadAndReturn(mockStructureNoOverrides)
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.reference).toBe('COMP-MOT-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses prixOverride when present', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.prix).toBe(13000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to composant.prix when prixOverride is null', async () => {
|
||||||
|
const { components } = await loadAndReturn(mockStructureNoOverrides)
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
expect(comp.prix).toBe(12500)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('overrides on piece links', () => {
|
||||||
|
it('uses piece nameOverride when present', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
expect(piece.name).toBe('Roulement 6205-RS')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to piece.name when nameOverride is null', async () => {
|
||||||
|
const { components } = await loadAndReturn(mockStructureNoOverrides)
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
expect(piece.name).toBe('Roulement 6205')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves piece reference from underlying entity when no override', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
// The override only has name, so reference comes from the piece entity
|
||||||
|
expect(piece.reference).toBe('ROUL-6205')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves piece prix from underlying entity when no override', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
expect(piece.prix).toBe(45.90)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 3. Custom field values on links (context fields)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('contextCustomFieldValues on component links', () => {
|
||||||
|
it('loads contextCustomFieldValues on component hierarchy nodes', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
const ctxValues = comp.contextCustomFieldValues as Record<string, unknown>[]
|
||||||
|
expect(ctxValues).toBeDefined()
|
||||||
|
expect(ctxValues).toHaveLength(1)
|
||||||
|
expect((ctxValues[0] as any).value).toBe('Bloc moteur gauche')
|
||||||
|
expect((ctxValues[0] as any).customField.name).toBe('Position sur machine')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads contextCustomFields definitions on component hierarchy nodes', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const comp = components.value[0]!
|
||||||
|
const ctxFields = comp.contextCustomFields as Record<string, unknown>[]
|
||||||
|
expect(ctxFields).toBeDefined()
|
||||||
|
expect(ctxFields).toHaveLength(1)
|
||||||
|
expect((ctxFields[0] as any).name).toBe('Position sur machine')
|
||||||
|
expect((ctxFields[0] as any).machineContextOnly).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('contextCustomFieldValues on piece links', () => {
|
||||||
|
it('loads contextCustomFieldValues on piece hierarchy nodes', async () => {
|
||||||
|
const { components } = await loadAndReturn()
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
const ctxValues = piece.contextCustomFieldValues as Record<string, unknown>[]
|
||||||
|
expect(ctxValues).toBeDefined()
|
||||||
|
expect(ctxValues).toHaveLength(1)
|
||||||
|
expect((ctxValues[0] as any).value).toBe('Cote entrainement')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has empty contextCustomFieldValues when none provided', async () => {
|
||||||
|
const { components } = await loadAndReturn(mockStructureNoOverrides)
|
||||||
|
|
||||||
|
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
|
||||||
|
const ctxValues = piece.contextCustomFieldValues as Record<string, unknown>[]
|
||||||
|
expect(ctxValues).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 4. Constructeur links on machine
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('constructeur links on machine', () => {
|
||||||
|
it('parses constructeur links from machine data', async () => {
|
||||||
|
const { constructeurLinks } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(constructeurLinks.value).toHaveLength(1)
|
||||||
|
expect(constructeurLinks.value[0]!.constructeurId).toBe(CONSTRUCTEUR_ID)
|
||||||
|
expect(constructeurLinks.value[0]!.supplierReference).toBe('SKF-PH200')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('populates machineConstructeurIds from links', async () => {
|
||||||
|
const { machineConstructeurIds } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machineConstructeurIds.value).toContain(CONSTRUCTEUR_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores original constructeur links for cancel rollback', async () => {
|
||||||
|
const { originalConstructeurLinks } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(originalConstructeurLinks.value).toHaveLength(1)
|
||||||
|
expect(originalConstructeurLinks.value[0]!.constructeurId).toBe(CONSTRUCTEUR_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hasMachineConstructeur is true when constructeur present', async () => {
|
||||||
|
const { hasMachineConstructeur } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(hasMachineConstructeur.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves constructeur display objects', async () => {
|
||||||
|
const { machineConstructeursDisplay } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machineConstructeursDisplay.value.length).toBeGreaterThanOrEqual(1)
|
||||||
|
const display = machineConstructeursDisplay.value[0] as any
|
||||||
|
expect(display.name).toBe('SKF')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 5. Site (required)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('site loaded with machine data', () => {
|
||||||
|
it('machineSiteId is populated from machine payload', async () => {
|
||||||
|
const { machineSiteId } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(machineSiteId.value).toBe(SITE_ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sites ref is available for dropdowns', async () => {
|
||||||
|
const { sites } = await loadAndReturn()
|
||||||
|
|
||||||
|
expect(sites.value).toHaveLength(1)
|
||||||
|
expect((sites.value[0] as any).name).toBe('Usine Nord')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('machine.site object is preserved in machine ref', async () => {
|
||||||
|
const { machine } = await loadAndReturn()
|
||||||
|
|
||||||
|
const site = machine.value!.site as Record<string, unknown>
|
||||||
|
expect(site.id).toBe(SITE_ID)
|
||||||
|
expect(site.name).toBe('Usine Nord')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 6. UI state defaults
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('UI state defaults', () => {
|
||||||
|
it('isEditMode starts as false', () => {
|
||||||
|
const { isEditMode } = useMachineDetailData(MACHINE_ID)
|
||||||
|
expect(isEditMode.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saving starts as false', () => {
|
||||||
|
const { saving } = useMachineDetailData(MACHINE_ID)
|
||||||
|
expect(saving.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loading starts as true', () => {
|
||||||
|
const { loading } = useMachineDetailData(MACHINE_ID)
|
||||||
|
expect(loading.value).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
644
frontend/tests/composables/usePieceEdit.test.ts
Normal file
644
frontend/tests/composables/usePieceEdit.test.ts
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
mockPieceFromApi,
|
||||||
|
mockLinkSKF,
|
||||||
|
mockLinkFAG,
|
||||||
|
mockConstructeurSKF,
|
||||||
|
mockConstructeurFAG,
|
||||||
|
wrapCollection,
|
||||||
|
} from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import under test (AFTER all vi.mock calls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { usePieceEdit } from '~/composables/usePieceEdit'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — API layer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
const mockPostFormData = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: mockPostFormData,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — Toast
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockShowSuccess = vi.fn()
|
||||||
|
const mockShowError = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: mockShowSuccess,
|
||||||
|
showError: mockShowError,
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePieces (updatePiece)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUpdatePiece = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieces', () => ({
|
||||||
|
usePieces: () => ({
|
||||||
|
updatePiece: mockUpdatePiece,
|
||||||
|
pieces: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePieceTypes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockPieceTypes = { value: [] as any[] }
|
||||||
|
const mockLoadPieceTypes = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
vi.mock('~/composables/usePieceTypes', () => ({
|
||||||
|
usePieceTypes: () => ({
|
||||||
|
pieceTypes: mockPieceTypes,
|
||||||
|
loadPieceTypes: mockLoadPieceTypes,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useDocuments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useDocuments', () => ({
|
||||||
|
useDocuments: () => ({
|
||||||
|
loadDocumentsByPiece: vi.fn().mockResolvedValue({ success: true, data: [] }),
|
||||||
|
uploadDocuments: vi.fn().mockResolvedValue({ success: true, data: [] }),
|
||||||
|
deleteDocument: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
documents: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurLinks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockFetchLinks = vi.fn().mockResolvedValue([])
|
||||||
|
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurLinks', () => ({
|
||||||
|
useConstructeurLinks: () => ({
|
||||||
|
fetchLinks: mockFetchLinks,
|
||||||
|
syncLinks: mockSyncLinks,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useCustomFieldInputs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockSaveAll = vi.fn().mockResolvedValue([])
|
||||||
|
const mockRefreshCF = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useCustomFieldInputs', () => ({
|
||||||
|
useCustomFieldInputs: () => ({
|
||||||
|
fields: { value: [] },
|
||||||
|
requiredFilled: { value: true },
|
||||||
|
saveAll: mockSaveAll,
|
||||||
|
refresh: mockRefreshCF,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — usePermissions (auto-imported in Nuxt)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.stubGlobal('usePermissions', () => ({
|
||||||
|
canEdit: { value: true },
|
||||||
|
canManage: { value: true },
|
||||||
|
isAdmin: { value: false },
|
||||||
|
isGranted: () => true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useConstructeurs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — useEntityHistory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/composables/useEntityHistory', () => ({
|
||||||
|
useEntityHistory: () => ({
|
||||||
|
history: { value: [] },
|
||||||
|
loading: { value: false },
|
||||||
|
error: { value: null },
|
||||||
|
loadHistory: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — shared utils
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('~/shared/modelUtils', () => ({
|
||||||
|
formatPieceStructurePreview: () => '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/constructeurUtils', () => ({
|
||||||
|
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
|
||||||
|
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/utils/documentPreview', () => ({
|
||||||
|
canPreviewDocument: () => false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/services/modelTypes', () => ({
|
||||||
|
getModelType: vi.fn().mockResolvedValue(null),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/shared/apiRelations', () => ({
|
||||||
|
extractRelationId: (rel: any) => {
|
||||||
|
if (typeof rel === 'string') return rel
|
||||||
|
if (rel && typeof rel === 'object' && 'id' in rel) return rel.id
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PIECE_ID = 'piece-001'
|
||||||
|
|
||||||
|
const mockPieceType = {
|
||||||
|
id: 'tp-bearing-001',
|
||||||
|
name: 'Roulement',
|
||||||
|
code: 'ROUL',
|
||||||
|
category: 'PIECE',
|
||||||
|
structure: {
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
typeProductId: 'tprod-grease-001',
|
||||||
|
typeProductLabel: 'Graisse SKF',
|
||||||
|
familyCode: 'LUB',
|
||||||
|
role: 'lubrification',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPieceWithProducts() {
|
||||||
|
return {
|
||||||
|
...mockPieceFromApi,
|
||||||
|
id: PIECE_ID,
|
||||||
|
'@id': `/api/pieces/${PIECE_ID}`,
|
||||||
|
description: 'Roulement haute performance',
|
||||||
|
prix: '42.50',
|
||||||
|
typePieceId: 'tp-bearing-001',
|
||||||
|
productIds: ['prod-001'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const tick = () => new Promise(r => setTimeout(r, 0))
|
||||||
|
|
||||||
|
async function createAndHydrate(overrides?: Record<string, any>) {
|
||||||
|
const pieceData = { ...buildPieceWithProducts(), ...overrides }
|
||||||
|
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url.includes(`/pieces/${PIECE_ID}`)) {
|
||||||
|
return Promise.resolve({ success: true, data: structuredClone(pieceData) })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true, data: wrapCollection([]) })
|
||||||
|
})
|
||||||
|
|
||||||
|
mockFetchLinks.mockResolvedValue([
|
||||||
|
{ ...mockLinkSKF },
|
||||||
|
{ ...mockLinkFAG },
|
||||||
|
])
|
||||||
|
|
||||||
|
const composable = usePieceEdit(PIECE_ID)
|
||||||
|
|
||||||
|
await composable.fetchPiece()
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
return composable
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// beforeEach
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockPieceTypes.value = [mockPieceType]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// fetchPiece — hydration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('fetchPiece — hydration', () => {
|
||||||
|
it('loads all simple fields (name, reference, description, prix)', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(composable.editionForm.name).toBe('Roulement 6205')
|
||||||
|
expect(composable.editionForm.reference).toBe('ROUL-6205')
|
||||||
|
expect(composable.editionForm.description).toBe('Roulement haute performance')
|
||||||
|
expect(composable.editionForm.prix).toBe('42.50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads piece with product slots', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(composable.piece.value).not.toBeNull()
|
||||||
|
expect(composable.piece.value.productSlots).toHaveLength(1)
|
||||||
|
expect(composable.piece.value.productSlots[0].product.id).toBe('prod-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads constructeur links via fetchLinks', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
expect(mockFetchLinks).toHaveBeenCalledWith('piece', PIECE_ID)
|
||||||
|
expect(composable.constructeurLinks.value).toHaveLength(2)
|
||||||
|
expect(composable.constructeurLinks.value[0].constructeurId).toBe(mockConstructeurSKF.id)
|
||||||
|
expect(composable.constructeurLinks.value[1].constructeurId).toBe(mockConstructeurFAG.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Product selections
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('product selections', () => {
|
||||||
|
it('setProductSelection updates the correct index', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// The structure has 1 product requirement, so productSelections should have 1 entry
|
||||||
|
composable.setProductSelection(0, 'prod-new-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
expect(composable.productSelections.value[0]).toBe('prod-new-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setProductSelection to null does not crash', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Set then clear
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
composable.setProductSelection(0, null)
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
expect(composable.productSelections.value[0]).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// submitEdition — no data loss
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitEdition — no data loss', () => {
|
||||||
|
it('sends all form fields in update payload', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.editionForm.name = 'Roulement modifie'
|
||||||
|
composable.editionForm.description = 'Nouvelle description'
|
||||||
|
composable.editionForm.reference = 'REF-MOD-001'
|
||||||
|
composable.editionForm.prix = '99.99'
|
||||||
|
|
||||||
|
// Ensure product selection is filled so submit proceeds
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
name: 'Roulement modifie',
|
||||||
|
description: 'Nouvelle description',
|
||||||
|
reference: 'REF-MOD-001',
|
||||||
|
prix: '99.99',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves custom fields after piece update', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs constructeur links', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
const [entityType, entityId, origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
||||||
|
expect(entityType).toBe('piece')
|
||||||
|
expect(entityId).toBe(PIECE_ID)
|
||||||
|
expect(origLinks).toHaveLength(2)
|
||||||
|
expect(formLinks).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editing name does not lose constructeur links', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Only edit name
|
||||||
|
composable.editionForm.name = 'Nouveau nom piece'
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.name).toBe('Nouveau nom piece')
|
||||||
|
|
||||||
|
// syncLinks still called with constructeur links preserved
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
||||||
|
expect(origLinks).toHaveLength(2)
|
||||||
|
expect(formLinks).toHaveLength(2)
|
||||||
|
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
|
||||||
|
expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editing name does not lose product slots', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Set product selection
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Now edit only name
|
||||||
|
composable.editionForm.name = 'Autre nom'
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.name).toBe('Autre nom')
|
||||||
|
// productIds should still contain the selection
|
||||||
|
expect(payload.productIds).toContain('prod-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adding a constructeur preserves existing ones', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
// Initially has SKF + FAG from fetchLinks
|
||||||
|
expect(composable.constructeurLinks.value).toHaveLength(2)
|
||||||
|
|
||||||
|
// Add a third constructeur
|
||||||
|
const newLink = {
|
||||||
|
linkId: null as string | null,
|
||||||
|
constructeurId: 'cstr-new-003',
|
||||||
|
constructeur: { id: 'cstr-new-003', name: 'NEW Corp', email: null, phone: null },
|
||||||
|
supplierReference: 'NEW-REF-001',
|
||||||
|
}
|
||||||
|
composable.constructeurLinks.value = [
|
||||||
|
...composable.constructeurLinks.value,
|
||||||
|
newLink,
|
||||||
|
]
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
|
||||||
|
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
|
||||||
|
// Original had 2 (SKF + FAG)
|
||||||
|
expect(origLinks).toHaveLength(2)
|
||||||
|
// Form now has 3 (SKF + FAG + NEW)
|
||||||
|
expect(formLinks).toHaveLength(3)
|
||||||
|
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
|
||||||
|
expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id)
|
||||||
|
expect(formLinks[2].constructeurId).toBe('cstr-new-003')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends both productId and productIds in payload', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.productId).toBe('prod-001')
|
||||||
|
expect(payload.productIds).toEqual(['prod-001'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('productId is the first product selection when multiple exist', async () => {
|
||||||
|
// Override the piece type to have 2 product requirements
|
||||||
|
const multiProductType = {
|
||||||
|
...mockPieceType,
|
||||||
|
structure: {
|
||||||
|
...mockPieceType.structure,
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
typeProductId: 'tprod-grease-001',
|
||||||
|
typeProductLabel: 'Graisse SKF',
|
||||||
|
familyCode: 'LUB',
|
||||||
|
role: 'lubrification',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeProductId: 'tprod-oil-002',
|
||||||
|
typeProductLabel: 'Huile',
|
||||||
|
familyCode: 'LUB',
|
||||||
|
role: 'lubrification secondaire',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFields: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mockPieceTypes.value = [multiProductType]
|
||||||
|
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate({
|
||||||
|
productIds: ['prod-001', 'prod-002'],
|
||||||
|
})
|
||||||
|
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
composable.setProductSelection(1, 'prod-002')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.productId).toBe('prod-001')
|
||||||
|
expect(payload.productIds).toEqual(['prod-001', 'prod-002'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// submitEdition — null field handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitEdition — null field handling', () => {
|
||||||
|
it('empty prix sends null', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.editionForm.prix = ''
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('whitespace-only prix sends null', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.editionForm.prix = ' '
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty reference sends null', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.editionForm.reference = ''
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.reference).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('valid prix is sent as string number', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.editionForm.prix = '99.50'
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
const payload = mockUpdatePiece.mock.calls[0]![1]
|
||||||
|
expect(payload.prix).toBe('99.5')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// submitEdition — error paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('submitEdition — error paths', () => {
|
||||||
|
it('does not save custom fields when updatePiece fails', async () => {
|
||||||
|
mockUpdatePiece.mockResolvedValue({ success: false, error: 'Server error' })
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not save custom fields when updatePiece throws', async () => {
|
||||||
|
mockUpdatePiece.mockRejectedValue(new Error('Network failure'))
|
||||||
|
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
composable.setProductSelection(0, 'prod-001')
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockSaveAll).not.toHaveBeenCalled()
|
||||||
|
expect(mockSyncLinks).not.toHaveBeenCalled()
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith('Network failure')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when product selection is not filled', async () => {
|
||||||
|
const composable = await createAndHydrate()
|
||||||
|
|
||||||
|
// Clear product selection
|
||||||
|
composable.setProductSelection(0, null)
|
||||||
|
await tick()
|
||||||
|
|
||||||
|
await composable.submitEdition()
|
||||||
|
|
||||||
|
expect(mockUpdatePiece).not.toHaveBeenCalled()
|
||||||
|
expect(mockShowError).toHaveBeenCalledWith('Sélectionnez un produit conforme au squelette.')
|
||||||
|
})
|
||||||
|
})
|
||||||
166
frontend/tests/composables/usePieces.test.ts
Normal file
166
frontend/tests/composables/usePieces.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { usePieces } from '~/composables/usePieces'
|
||||||
|
import { mockPieceFromApi, wrapCollection } from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const { clearPiecesCache } = usePieces()
|
||||||
|
clearPiecesCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createPiece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('createPiece', () => {
|
||||||
|
it('sends all fields including prix in POST payload', async () => {
|
||||||
|
const created = { ...mockPieceFromApi, id: 'piece-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createPiece } = usePieces()
|
||||||
|
await createPiece({
|
||||||
|
name: 'Roulement 6205',
|
||||||
|
reference: 'ROUL-6205',
|
||||||
|
prix: 25.50,
|
||||||
|
typePieceId: 'tp-bearing-001',
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/pieces', expect.objectContaining({
|
||||||
|
name: 'Roulement 6205',
|
||||||
|
reference: 'ROUL-6205',
|
||||||
|
prix: 25.50,
|
||||||
|
typePiece: '/api/model_types/tp-bearing-001',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips constructeur fields from payload', async () => {
|
||||||
|
const created = { ...mockPieceFromApi, id: 'piece-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createPiece } = usePieces()
|
||||||
|
await createPiece({
|
||||||
|
name: 'Test Piece',
|
||||||
|
constructeurIds: ['cstr-skf-001'],
|
||||||
|
constructeurs: [{ id: 'cstr-skf-001', name: 'SKF' }] as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = mockPost.mock.calls[0]![1]
|
||||||
|
expect(payload).not.toHaveProperty('constructeurIds')
|
||||||
|
expect(payload).not.toHaveProperty('constructeurs')
|
||||||
|
expect(payload).not.toHaveProperty('constructeurId')
|
||||||
|
expect(payload).not.toHaveProperty('constructeur')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds created piece to cache (pieces array and total)', async () => {
|
||||||
|
const created = { ...mockPieceFromApi, id: 'piece-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createPiece, pieces, total } = usePieces()
|
||||||
|
const result = await createPiece({ name: 'New Piece' })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(pieces.value).toHaveLength(1)
|
||||||
|
expect(pieces.value[0]!.id).toBe('piece-new')
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// updatePiece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('updatePiece', () => {
|
||||||
|
it('patches with supplied fields and updates cache', async () => {
|
||||||
|
// Seed cache first
|
||||||
|
const original = { ...mockPieceFromApi }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: original })
|
||||||
|
const { createPiece, updatePiece, pieces } = usePieces()
|
||||||
|
await createPiece({ name: 'Roulement 6205' })
|
||||||
|
|
||||||
|
const updated = { ...mockPieceFromApi, name: 'Updated Name', reference: 'ROUL-NEW' }
|
||||||
|
mockPatch.mockResolvedValue({ success: true, data: updated })
|
||||||
|
|
||||||
|
const result = await updatePiece(mockPieceFromApi.id, {
|
||||||
|
name: 'Updated Name',
|
||||||
|
reference: 'ROUL-NEW',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(`/pieces/${mockPieceFromApi.id}`, expect.objectContaining({
|
||||||
|
name: 'Updated Name',
|
||||||
|
reference: 'ROUL-NEW',
|
||||||
|
}))
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(pieces.value.find(p => p.id === mockPieceFromApi.id)?.name).toBe('Updated Name')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deletePiece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('deletePiece', () => {
|
||||||
|
it('removes piece from cache on success', async () => {
|
||||||
|
// Seed cache
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: { ...mockPieceFromApi } })
|
||||||
|
const { createPiece, deletePiece, pieces, total } = usePieces()
|
||||||
|
await createPiece({ name: 'To Delete' })
|
||||||
|
expect(pieces.value).toHaveLength(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
const result = await deletePiece(mockPieceFromApi.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(pieces.value).toHaveLength(0)
|
||||||
|
expect(total.value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not remove on failure', async () => {
|
||||||
|
// Seed cache
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: { ...mockPieceFromApi } })
|
||||||
|
const { createPiece, deletePiece, pieces, total } = usePieces()
|
||||||
|
await createPiece({ name: 'Should Stay' })
|
||||||
|
expect(pieces.value).toHaveLength(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
|
||||||
|
const result = await deletePiece(mockPieceFromApi.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(pieces.value).toHaveLength(1)
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
209
frontend/tests/composables/useProducts.test.ts
Normal file
209
frontend/tests/composables/useProducts.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { useProducts } from '~/composables/useProducts'
|
||||||
|
import { mockProductFromApi, mockConstructeurSKF, wrapCollection } from '../fixtures/mockData'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDel = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useApi', () => ({
|
||||||
|
useApi: () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
patch: mockPatch,
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: mockDel,
|
||||||
|
postFormData: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn(),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
toasts: { value: [] },
|
||||||
|
clearAll: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/composables/useConstructeurs', () => ({
|
||||||
|
useConstructeurs: () => ({
|
||||||
|
ensureConstructeurs: vi.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const { clearProductsCache } = useProducts()
|
||||||
|
clearProductsCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createProduct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('createProduct', () => {
|
||||||
|
it('sends all fields including supplierPrice in POST payload', async () => {
|
||||||
|
const created = { ...mockProductFromApi, id: 'prod-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createProduct } = useProducts()
|
||||||
|
await createProduct({
|
||||||
|
name: 'Graisse LGMT2',
|
||||||
|
reference: 'LUB-LGMT2',
|
||||||
|
supplierPrice: 45.90,
|
||||||
|
typeProductId: 'tprod-grease-001',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/products', expect.objectContaining({
|
||||||
|
name: 'Graisse LGMT2',
|
||||||
|
reference: 'LUB-LGMT2',
|
||||||
|
supplierPrice: 45.90,
|
||||||
|
typeProduct: '/api/model_types/tprod-grease-001',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips constructeur fields from payload', async () => {
|
||||||
|
const created = { ...mockProductFromApi, id: 'prod-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createProduct } = useProducts()
|
||||||
|
await createProduct({
|
||||||
|
name: 'Test Product',
|
||||||
|
constructeurIds: ['cstr-skf-001'],
|
||||||
|
constructeurs: [mockConstructeurSKF] as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = mockPost.mock.calls[0]![1]
|
||||||
|
expect(payload).not.toHaveProperty('constructeurIds')
|
||||||
|
expect(payload).not.toHaveProperty('constructeurs')
|
||||||
|
expect(payload).not.toHaveProperty('constructeurId')
|
||||||
|
expect(payload).not.toHaveProperty('constructeur')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds created product to cache (products array and total)', async () => {
|
||||||
|
const created = { ...mockProductFromApi, id: 'prod-new' }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: created })
|
||||||
|
|
||||||
|
const { createProduct, products, total } = useProducts()
|
||||||
|
const result = await createProduct({ name: 'New Product' })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(products.value).toHaveLength(1)
|
||||||
|
expect(products.value[0]!.id).toBe('prod-new')
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// updateProduct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('updateProduct', () => {
|
||||||
|
it('patches with supplied fields and updates cache', async () => {
|
||||||
|
// Seed cache first
|
||||||
|
const original = { ...mockProductFromApi }
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: original })
|
||||||
|
const { createProduct, updateProduct, products } = useProducts()
|
||||||
|
await createProduct({ name: 'Graisse LGMT2' })
|
||||||
|
|
||||||
|
const updated = { ...mockProductFromApi, name: 'Updated Name', supplierPrice: 99.99 }
|
||||||
|
mockPatch.mockResolvedValue({ success: true, data: updated })
|
||||||
|
|
||||||
|
const result = await updateProduct(mockProductFromApi.id, {
|
||||||
|
name: 'Updated Name',
|
||||||
|
supplierPrice: 99.99,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(`/products/${mockProductFromApi.id}`, expect.objectContaining({
|
||||||
|
name: 'Updated Name',
|
||||||
|
supplierPrice: 99.99,
|
||||||
|
}))
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(products.value.find(p => p.id === mockProductFromApi.id)?.name).toBe('Updated Name')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteProduct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('deleteProduct', () => {
|
||||||
|
it('removes product from cache on success', async () => {
|
||||||
|
// Seed cache
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: { ...mockProductFromApi } })
|
||||||
|
const { createProduct, deleteProduct, products, total } = useProducts()
|
||||||
|
await createProduct({ name: 'To Delete' })
|
||||||
|
expect(products.value).toHaveLength(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: true })
|
||||||
|
const result = await deleteProduct(mockProductFromApi.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(products.value).toHaveLength(0)
|
||||||
|
expect(total.value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not remove on failure', async () => {
|
||||||
|
// Seed cache
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: { ...mockProductFromApi } })
|
||||||
|
const { createProduct, deleteProduct, products, total } = useProducts()
|
||||||
|
await createProduct({ name: 'Should Stay' })
|
||||||
|
expect(products.value).toHaveLength(1)
|
||||||
|
|
||||||
|
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
|
||||||
|
const result = await deleteProduct(mockProductFromApi.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(products.value).toHaveLength(1)
|
||||||
|
expect(total.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getProduct
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('getProduct', () => {
|
||||||
|
it('returns cached product if available with constructeurs (no extra API call)', async () => {
|
||||||
|
// Seed cache with a product that has resolved constructeurs
|
||||||
|
const productWithConstructeurs = {
|
||||||
|
...mockProductFromApi,
|
||||||
|
constructeurs: [mockConstructeurSKF],
|
||||||
|
}
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: productWithConstructeurs })
|
||||||
|
const { createProduct, getProduct } = useProducts()
|
||||||
|
await createProduct({ name: 'Cached' })
|
||||||
|
|
||||||
|
mockGet.mockClear()
|
||||||
|
const result = await getProduct(mockProductFromApi.id)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.id).toBe(mockProductFromApi.id)
|
||||||
|
expect(mockGet).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches from API with force: true', async () => {
|
||||||
|
// Seed cache with a product that has resolved constructeurs
|
||||||
|
const productWithConstructeurs = {
|
||||||
|
...mockProductFromApi,
|
||||||
|
constructeurs: [mockConstructeurSKF],
|
||||||
|
}
|
||||||
|
mockPost.mockResolvedValue({ success: true, data: productWithConstructeurs })
|
||||||
|
const { createProduct, getProduct } = useProducts()
|
||||||
|
await createProduct({ name: 'Cached' })
|
||||||
|
|
||||||
|
const freshData = { ...mockProductFromApi, name: 'Fresh from API' }
|
||||||
|
mockGet.mockResolvedValue({ success: true, data: freshData })
|
||||||
|
|
||||||
|
const result = await getProduct(mockProductFromApi.id, { force: true })
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith(`/products/${mockProductFromApi.id}`)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(result.data?.name).toBe('Fresh from API')
|
||||||
|
})
|
||||||
|
})
|
||||||
438
frontend/tests/fixtures/mockData.ts
vendored
Normal file
438
frontend/tests/fixtures/mockData.ts
vendored
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared mock data for Inventory frontend test suite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { ConstructeurLinkEntry, ConstructeurSummary } from '~/shared/constructeurUtils'
|
||||||
|
import type { CustomFieldDefinition, CustomFieldValue } from '~/shared/utils/customFields'
|
||||||
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constructeurs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockConstructeurSKF: ConstructeurSummary = {
|
||||||
|
id: 'cstr-skf-001',
|
||||||
|
name: 'SKF',
|
||||||
|
email: 'contact@skf.com',
|
||||||
|
phone: '+33 1 23 45 67 89',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockConstructeurFAG: ConstructeurSummary = {
|
||||||
|
id: 'cstr-fag-002',
|
||||||
|
name: 'FAG',
|
||||||
|
email: 'info@fag.de',
|
||||||
|
phone: '+49 9721 91 0',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constructeur link entries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockLinkSKF: ConstructeurLinkEntry = {
|
||||||
|
linkId: 'link-skf-001',
|
||||||
|
constructeurId: mockConstructeurSKF.id,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: 'SKF-6205-2RS',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockLinkFAG: ConstructeurLinkEntry = {
|
||||||
|
linkId: 'link-fag-002',
|
||||||
|
constructeurId: mockConstructeurFAG.id,
|
||||||
|
constructeur: mockConstructeurFAG,
|
||||||
|
supplierReference: 'FAG-6205-C-2HRS',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom field definitions (6 types)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockCustomFieldDefs: CustomFieldDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'cf-def-001',
|
||||||
|
name: 'Tension nominale',
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: '220',
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cf-def-002',
|
||||||
|
name: 'Certifié CE',
|
||||||
|
type: 'boolean',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: 'false',
|
||||||
|
orderIndex: 1,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cf-def-003',
|
||||||
|
name: 'Indice de protection',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
options: ['IP54', 'IP55', 'IP65'],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 2,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cf-def-004',
|
||||||
|
name: 'Date de calibration',
|
||||||
|
type: 'date',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 3,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cf-def-005',
|
||||||
|
name: 'Remarques techniques',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 4,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cf-def-006',
|
||||||
|
name: 'Position sur machine',
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 5,
|
||||||
|
machineContextOnly: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom field values (matching first 5 defs)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockCustomFieldValues: CustomFieldValue[] = [
|
||||||
|
{
|
||||||
|
id: 'cfv-001',
|
||||||
|
value: '220',
|
||||||
|
customField: mockCustomFieldDefs[0]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfv-002',
|
||||||
|
value: 'true',
|
||||||
|
customField: mockCustomFieldDefs[1]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfv-003',
|
||||||
|
value: 'IP65',
|
||||||
|
customField: mockCustomFieldDefs[2]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfv-004',
|
||||||
|
value: '2025-06-15',
|
||||||
|
customField: mockCustomFieldDefs[3]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfv-005',
|
||||||
|
value: 'Roulement renforcé pour environnement humide',
|
||||||
|
customField: mockCustomFieldDefs[4]!,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component ModelType structure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockComponentStructure: ComponentModelStructure = {
|
||||||
|
customFields: [
|
||||||
|
{ name: 'Tension nominale', type: 'number', required: true, defaultValue: '220', orderIndex: 0 },
|
||||||
|
{ name: 'Certifié CE', type: 'boolean', required: false, defaultValue: 'false', orderIndex: 1 },
|
||||||
|
{ name: 'Indice de protection', type: 'select', required: false, options: ['IP54', 'IP55', 'IP65'], orderIndex: 2 },
|
||||||
|
],
|
||||||
|
pieces: [
|
||||||
|
{
|
||||||
|
typePieceId: 'tp-bearing-001',
|
||||||
|
typePieceLabel: 'Roulement',
|
||||||
|
reference: 'REF-PIECE-001',
|
||||||
|
familyCode: 'ROUL',
|
||||||
|
role: 'support',
|
||||||
|
quantity: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typePieceId: 'tp-seal-002',
|
||||||
|
typePieceLabel: 'Joint',
|
||||||
|
reference: 'REF-PIECE-002',
|
||||||
|
familyCode: 'JOINT',
|
||||||
|
role: 'étanchéité',
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
typeProductId: 'tprod-grease-001',
|
||||||
|
typeProductLabel: 'Graisse SKF',
|
||||||
|
reference: 'REF-PROD-001',
|
||||||
|
familyCode: 'LUB',
|
||||||
|
role: 'lubrification',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subcomponents: [
|
||||||
|
{
|
||||||
|
typeComposantId: 'tc-sub-001',
|
||||||
|
typeComposantLabel: 'Sous-ensemble palier',
|
||||||
|
familyCode: 'PAL',
|
||||||
|
alias: 'Palier avant',
|
||||||
|
subcomponents: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full API response — Composant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockComponentFromApi = {
|
||||||
|
'@id': '/api/composants/comp-001',
|
||||||
|
'@type': 'Composant',
|
||||||
|
id: 'comp-001',
|
||||||
|
name: 'Moteur principal',
|
||||||
|
reference: 'COMP-MOT-001',
|
||||||
|
typeComposant: { id: 'tc-moteur', name: 'Moteur électrique', code: 'MOT' },
|
||||||
|
site: { id: 'site-001', name: 'Usine Nord' },
|
||||||
|
pieceSlots: [
|
||||||
|
{
|
||||||
|
id: 'ps-001',
|
||||||
|
piece: { id: 'piece-001', name: 'Roulement 6205', reference: 'ROUL-6205' },
|
||||||
|
typePiece: { id: 'tp-bearing-001', name: 'Roulement' },
|
||||||
|
role: 'support',
|
||||||
|
quantity: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ps-002',
|
||||||
|
piece: { id: 'piece-002', name: 'Joint torique', reference: 'JOINT-001' },
|
||||||
|
typePiece: { id: 'tp-seal-002', name: 'Joint' },
|
||||||
|
role: 'étanchéité',
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
productSlots: [
|
||||||
|
{
|
||||||
|
id: 'prs-001',
|
||||||
|
product: { id: 'prod-001', name: 'Graisse LGMT2', reference: 'LUB-LGMT2' },
|
||||||
|
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF' },
|
||||||
|
role: 'lubrification',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subcomponentSlots: [
|
||||||
|
{
|
||||||
|
id: 'scs-001',
|
||||||
|
subcomponent: { id: 'comp-sub-001', name: 'Palier avant', reference: 'PAL-AV-001' },
|
||||||
|
typeComposant: { id: 'tc-sub-001', name: 'Sous-ensemble palier' },
|
||||||
|
alias: 'Palier avant',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
constructeurLinks: [
|
||||||
|
{
|
||||||
|
id: mockLinkSKF.linkId,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: mockLinkSKF.supplierReference,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFieldValues: mockCustomFieldValues.map(cfv => ({
|
||||||
|
id: cfv.id,
|
||||||
|
value: cfv.value,
|
||||||
|
customField: {
|
||||||
|
id: cfv.customField.id,
|
||||||
|
name: cfv.customField.name,
|
||||||
|
type: cfv.customField.type,
|
||||||
|
required: cfv.customField.required,
|
||||||
|
options: cfv.customField.options,
|
||||||
|
defaultValue: cfv.customField.defaultValue,
|
||||||
|
orderIndex: cfv.customField.orderIndex,
|
||||||
|
machineContextOnly: cfv.customField.machineContextOnly,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
createdAt: '2025-01-15T10:00:00+00:00',
|
||||||
|
updatedAt: '2025-03-20T14:30:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full API response — Piece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockPieceFromApi = {
|
||||||
|
'@id': '/api/pieces/piece-001',
|
||||||
|
'@type': 'Piece',
|
||||||
|
id: 'piece-001',
|
||||||
|
name: 'Roulement 6205',
|
||||||
|
reference: 'ROUL-6205',
|
||||||
|
typePiece: { id: 'tp-bearing-001', name: 'Roulement', code: 'ROUL' },
|
||||||
|
site: { id: 'site-001', name: 'Usine Nord' },
|
||||||
|
productSlots: [
|
||||||
|
{
|
||||||
|
id: 'pps-001',
|
||||||
|
product: { id: 'prod-001', name: 'Graisse LGMT2', reference: 'LUB-LGMT2' },
|
||||||
|
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF' },
|
||||||
|
role: 'lubrification',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
constructeurLinks: [
|
||||||
|
{
|
||||||
|
id: mockLinkSKF.linkId,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: mockLinkSKF.supplierReference,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: mockLinkFAG.linkId,
|
||||||
|
constructeur: mockConstructeurFAG,
|
||||||
|
supplierReference: mockLinkFAG.supplierReference,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFieldValues: [
|
||||||
|
{
|
||||||
|
id: 'cfv-piece-001',
|
||||||
|
value: '6205',
|
||||||
|
customField: {
|
||||||
|
id: 'cf-piece-def-001',
|
||||||
|
name: 'Référence interne',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: '2025-01-10T08:00:00+00:00',
|
||||||
|
updatedAt: '2025-03-18T11:00:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Full API response — Product
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockProductFromApi = {
|
||||||
|
'@id': '/api/products/prod-001',
|
||||||
|
'@type': 'Product',
|
||||||
|
id: 'prod-001',
|
||||||
|
name: 'Graisse LGMT2',
|
||||||
|
reference: 'LUB-LGMT2',
|
||||||
|
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF', code: 'LUB' },
|
||||||
|
site: { id: 'site-001', name: 'Usine Nord' },
|
||||||
|
supplierPrice: 45.90,
|
||||||
|
constructeurLinks: [
|
||||||
|
{
|
||||||
|
id: mockLinkSKF.linkId,
|
||||||
|
constructeur: mockConstructeurSKF,
|
||||||
|
supplierReference: 'LGMT2/1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: '2025-02-01T09:00:00+00:00',
|
||||||
|
updatedAt: '2025-03-10T16:00:00+00:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON-LD collection wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function wrapCollection<T>(items: T[], total?: number) {
|
||||||
|
return {
|
||||||
|
'@context': '/api/contexts/Collection',
|
||||||
|
'@id': '/api/collection',
|
||||||
|
'@type': 'Collection',
|
||||||
|
'totalItems': total ?? items.length,
|
||||||
|
'member': items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Machine custom field definitions (5 types)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockMachineCustomFieldDefs: CustomFieldDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'mcf-def-001',
|
||||||
|
name: 'Numéro de série',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 0,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcf-def-002',
|
||||||
|
name: 'En service',
|
||||||
|
type: 'boolean',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: 'false',
|
||||||
|
orderIndex: 1,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcf-def-003',
|
||||||
|
name: 'Puissance (kW)',
|
||||||
|
type: 'number',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 2,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcf-def-004',
|
||||||
|
name: 'Catégorie ATEX',
|
||||||
|
type: 'select',
|
||||||
|
required: false,
|
||||||
|
options: ['Zone 0', 'Zone 1', 'Zone 2', 'Non classé'],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 3,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcf-def-005',
|
||||||
|
name: 'Date mise en service',
|
||||||
|
type: 'date',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
defaultValue: null,
|
||||||
|
orderIndex: 4,
|
||||||
|
machineContextOnly: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Machine custom field values (matching defs, includes number '0' and boolean 'true')
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mockMachineCustomFieldValues: CustomFieldValue[] = [
|
||||||
|
{
|
||||||
|
id: 'mcfv-001',
|
||||||
|
value: 'SN-2025-001234',
|
||||||
|
customField: mockMachineCustomFieldDefs[0]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcfv-002',
|
||||||
|
value: 'true',
|
||||||
|
customField: mockMachineCustomFieldDefs[1]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcfv-003',
|
||||||
|
value: '0',
|
||||||
|
customField: mockMachineCustomFieldDefs[2]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcfv-004',
|
||||||
|
value: 'Zone 1',
|
||||||
|
customField: mockMachineCustomFieldDefs[3]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcfv-005',
|
||||||
|
value: '2025-01-15',
|
||||||
|
customField: mockMachineCustomFieldDefs[4]!,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -7,6 +7,10 @@ services:
|
|||||||
- "8082:80"
|
- "8082:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/var/www/html/var/storage/documents
|
- ./storage:/var/www/html/var/storage/documents
|
||||||
|
- inventory_logs:/var/www/html/var/log
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
inventory_logs:
|
||||||
|
|||||||
6
makefile
6
makefile
@@ -127,6 +127,12 @@ php-cs-fixer-allow-risky:
|
|||||||
test:
|
test:
|
||||||
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
||||||
|
|
||||||
|
test-front:
|
||||||
|
cd frontend && npx vitest run $(FILES)
|
||||||
|
|
||||||
|
test-front-watch:
|
||||||
|
cd frontend && npx vitest --watch $(FILES)
|
||||||
|
|
||||||
test-setup:
|
test-setup:
|
||||||
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists --env=test
|
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists --env=test
|
||||||
$(SYMFONY_CONSOLE) doctrine:schema:update --force --env=test
|
$(SYMFONY_CONSOLE) doctrine:schema:update --force --env=test
|
||||||
|
|||||||
106
migrations/Version20260506140000_FixComposantCascadeFKs.php
Normal file
106
migrations/Version20260506140000_FixComposantCascadeFKs.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260506140000_FixComposantCascadeFKs extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add missing CASCADE FKs documents.composantid and machine_component_links.composantid; cleanup pre-existing orphan rows';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1. Trace des suppressions à venir dans audit_logs (actor = NULL = "system").
|
||||||
|
// On copie un snapshot minimal avant DELETE pour cohérence avec les autres "delete".
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'document',
|
||||||
|
d.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', d.id,
|
||||||
|
'name', d.name,
|
||||||
|
'filename', d.filename,
|
||||||
|
'composantId', d.composantid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM documents d
|
||||||
|
WHERE d.composantid IS NOT NULL
|
||||||
|
AND d.composantid NOT IN (SELECT id FROM composants)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'machine_component_link',
|
||||||
|
l.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', l.id,
|
||||||
|
'machineId', l.machineid,
|
||||||
|
'composantId', l.composantid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM machine_component_links l
|
||||||
|
WHERE l.composantid IS NOT NULL
|
||||||
|
AND l.composantid NOT IN (SELECT id FROM composants)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 2. Nettoyage des orphelins.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM documents
|
||||||
|
WHERE composantid IS NOT NULL
|
||||||
|
AND composantid NOT IN (SELECT id FROM composants)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM machine_component_links
|
||||||
|
WHERE composantid IS NOT NULL
|
||||||
|
AND composantid NOT IN (SELECT id FROM composants)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 3. Ajout idempotent des 2 FK manquantes (alignement avec les entités Doctrine).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_name = 'fk_documents_composant' AND table_name = 'documents'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT fk_documents_composant
|
||||||
|
FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE constraint_name = 'fk_mcl_composant' AND table_name = 'machine_component_links'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE machine_component_links ADD CONSTRAINT fk_mcl_composant
|
||||||
|
FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_composant');
|
||||||
|
$this->addSql('ALTER TABLE machine_component_links DROP CONSTRAINT IF EXISTS fk_mcl_composant');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260512150000_AddConstructeurCategoriesAndPhones extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add constructeur_categorie + constructeur_categories (M2M) + constructeur_telephone (1-N); migrate constructeurs.phone into constructeur_telephone then drop the phone column';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1. Référentiel de catégories de fournisseurs.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS constructeur_categorie (
|
||||||
|
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 2. Table de jointure many-to-many fournisseur <-> catégorie.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS constructeur_categories (
|
||||||
|
constructeur_id VARCHAR(36) NOT NULL REFERENCES constructeurs(id) ON DELETE CASCADE,
|
||||||
|
categorie_id VARCHAR(36) NOT NULL REFERENCES constructeur_categorie(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY(constructeur_id, categorie_id)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX IF NOT EXISTS idx_constructeur_categories_categorie ON constructeur_categories (categorie_id)');
|
||||||
|
|
||||||
|
// 3. Téléphones (un fournisseur peut en avoir plusieurs).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS constructeur_telephone (
|
||||||
|
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
constructeurid VARCHAR(36) NOT NULL REFERENCES constructeurs(id) ON DELETE CASCADE,
|
||||||
|
numero VARCHAR(50) NOT NULL,
|
||||||
|
label VARCHAR(100) DEFAULT NULL,
|
||||||
|
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX IF NOT EXISTS idx_constructeur_telephone_constructeur ON constructeur_telephone (constructeurid)');
|
||||||
|
|
||||||
|
// 4. Migration des téléphones existants (colonne unique) vers la nouvelle table.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO constructeur_telephone (id, constructeurid, numero, label, createdat, updatedat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text || c.id), 1, 24),
|
||||||
|
c.id,
|
||||||
|
trim(c.phone),
|
||||||
|
NULL,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM constructeurs c
|
||||||
|
WHERE c.phone IS NOT NULL AND trim(c.phone) <> ''
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 5. La colonne unique n'est plus la source de vérité.
|
||||||
|
$this->addSql('ALTER TABLE constructeurs DROP COLUMN IF EXISTS phone');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE constructeurs ADD COLUMN IF NOT EXISTS phone VARCHAR(255) DEFAULT NULL');
|
||||||
|
|
||||||
|
// Restaure un téléphone par fournisseur (le plus récemment créé), best-effort.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE constructeurs c
|
||||||
|
SET phone = t.numero
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (constructeurid) constructeurid, numero
|
||||||
|
FROM constructeur_telephone
|
||||||
|
ORDER BY constructeurid, createdat DESC
|
||||||
|
) t
|
||||||
|
WHERE t.constructeurid = c.id
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS constructeur_telephone');
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS constructeur_categories');
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS constructeur_categorie');
|
||||||
|
}
|
||||||
|
}
|
||||||
320
src/Command/ConvertMoteurPieceToComponentCommand.php
Normal file
320
src/Command/ConvertMoteurPieceToComponentCommand.php
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:convert-moteur-piece-to-component',
|
||||||
|
description: 'Convertit la catégorie "Moteur" (PIECE) en COMPONENT et migre toutes les pièces liées en composants.',
|
||||||
|
)]
|
||||||
|
class ConvertMoteurPieceToComponentCommand extends Command
|
||||||
|
{
|
||||||
|
private const MODEL_TYPE_ID = 'cmgytewe0002447ffup09bscr';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche les actions sans les exécuter');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$dryRun = $input->getOption('dry-run');
|
||||||
|
|
||||||
|
$io->title('Conversion catégorie "Moteur" : PIECE → COMPONENT');
|
||||||
|
|
||||||
|
// ── 1. Vérifications ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
$modelType = $this->connection->fetchAssociative(
|
||||||
|
'SELECT id, name, code, category FROM model_types WHERE id = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$modelType) {
|
||||||
|
$io->error('ModelType "Moteur" introuvable (id: '.self::MODEL_TYPE_ID.')');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('PIECE' !== $modelType['category']) {
|
||||||
|
$io->error(sprintf('Le ModelType "Moteur" est déjà de catégorie %s — rien à faire.', $modelType['category']));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pieces = $this->connection->fetchAllAssociative(
|
||||||
|
'SELECT id, name, reference FROM pieces WHERE typepieceid = :id ORDER BY name',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
$pieceCount = count($pieces);
|
||||||
|
$io->info(sprintf('Pièces à convertir : %d', $pieceCount));
|
||||||
|
|
||||||
|
if ($pieceCount > 0) {
|
||||||
|
$io->table(
|
||||||
|
['ID', 'Nom', 'Référence'],
|
||||||
|
array_map(fn (array $p) => [$p['id'], $p['name'], $p['reference'] ?? '—'], $pieces),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check blockers
|
||||||
|
$blockers = [];
|
||||||
|
|
||||||
|
$machineLinked = (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM machine_piece_links mpl
|
||||||
|
JOIN pieces p ON mpl.pieceid = p.id
|
||||||
|
WHERE p.typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($machineLinked > 0) {
|
||||||
|
$blockers[] = sprintf('%d pièce(s) liée(s) à des machines — conversion impossible.', $machineLinked);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nameCollisions = $this->connection->fetchFirstColumn(
|
||||||
|
'SELECT p.name FROM pieces p
|
||||||
|
WHERE p.typepieceid = :id
|
||||||
|
AND p.name IN (SELECT c.name FROM composants c)',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ([] !== $nameCollisions) {
|
||||||
|
$blockers[] = sprintf('Collision de noms avec des composants existants : %s', implode(', ', $nameCollisions));
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryCollision = (int) $this->connection->fetchOne(
|
||||||
|
"SELECT COUNT(*) FROM model_types WHERE category = 'COMPONENT' AND name = :name AND id != :id",
|
||||||
|
['name' => $modelType['name'], 'id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($categoryCollision > 0) {
|
||||||
|
$blockers[] = sprintf('Un ModelType composant « %s » existe déjà.', $modelType['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $blockers) {
|
||||||
|
$io->error($blockers);
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary of related data
|
||||||
|
$relatedCounts = $this->countRelatedData();
|
||||||
|
$io->section('Données liées à migrer');
|
||||||
|
$io->table(
|
||||||
|
['Table', 'Nombre'],
|
||||||
|
array_map(fn (string $k, int $v) => [$k, $v], array_keys($relatedCounts), array_values($relatedCounts)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$io->warning('Mode dry-run : aucune modification effectuée.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Exécution ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$this->connection->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$now = new DateTimeImmutable()->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
// 2a. Copier les pièces dans composants
|
||||||
|
$converted = $this->connection->executeStatement(
|
||||||
|
'INSERT INTO composants (id, name, reference, referenceauto, description, prix, typecomposantid, productid, version, createdat, updatedat)
|
||||||
|
SELECT id, name, reference, referenceauto, description, prix, typepieceid, productid, version, createdat, :now
|
||||||
|
FROM pieces
|
||||||
|
WHERE typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID, 'now' => $now],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d pièce(s) copiée(s) dans composants', $converted));
|
||||||
|
|
||||||
|
// 2b. Transférer les documents
|
||||||
|
$docs = $this->connection->executeStatement(
|
||||||
|
'UPDATE documents SET composantid = pieceid, pieceid = NULL
|
||||||
|
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d document(s) transféré(s)', $docs));
|
||||||
|
|
||||||
|
// 2c. Transférer les custom_field_values
|
||||||
|
$cfv = $this->connection->executeStatement(
|
||||||
|
'UPDATE custom_field_values SET composantid = pieceid, pieceid = NULL
|
||||||
|
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d valeur(s) de champs perso transférée(s)', $cfv));
|
||||||
|
|
||||||
|
// 2d. Transférer les custom_fields (définitions)
|
||||||
|
$cf = $this->connection->executeStatement(
|
||||||
|
'UPDATE custom_fields SET typecomposantid = typepieceid, typepieceid = NULL
|
||||||
|
WHERE typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d définition(s) de champs perso transférée(s)', $cf));
|
||||||
|
|
||||||
|
// 2e. Transférer les constructeur links
|
||||||
|
$ctorLinks = $this->connection->executeStatement(
|
||||||
|
"INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat)
|
||||||
|
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
pcl.pieceid, pcl.constructeurid, pcl.supplierreference, pcl.createdat, :now
|
||||||
|
FROM piece_constructeur_links pcl
|
||||||
|
WHERE pcl.pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)",
|
||||||
|
['id' => self::MODEL_TYPE_ID, 'now' => $now],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($ctorLinks > 0) {
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'DELETE FROM piece_constructeur_links
|
||||||
|
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$io->text(sprintf('✓ %d lien(s) constructeur transféré(s)', $ctorLinks));
|
||||||
|
|
||||||
|
// 2f. Convertir composant_piece_slots → composant_subcomponent_slots
|
||||||
|
$slots = $this->connection->executeStatement(
|
||||||
|
"INSERT INTO composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat)
|
||||||
|
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
cps.composantid,
|
||||||
|
COALESCE(sp.name, 'Moteur'),
|
||||||
|
'moteur',
|
||||||
|
cps.typepieceid,
|
||||||
|
cps.selectedpieceid,
|
||||||
|
cps.position,
|
||||||
|
cps.createdat,
|
||||||
|
:now
|
||||||
|
FROM composant_piece_slots cps
|
||||||
|
LEFT JOIN pieces sp ON sp.id = cps.selectedpieceid
|
||||||
|
WHERE cps.typepieceid = :id",
|
||||||
|
['id' => self::MODEL_TYPE_ID, 'now' => $now],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'DELETE FROM composant_piece_slots WHERE typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d slot(s) pièce convertis en slots sous-composant', $slots));
|
||||||
|
|
||||||
|
// 2g. Convertir skeleton_piece_requirements → skeleton_subcomponent_requirements
|
||||||
|
$skelReqs = $this->connection->executeStatement(
|
||||||
|
"INSERT INTO skeleton_subcomponent_requirements (id, modeltypeid, alias, familycode, typecomposantid, position, createdat, updatedat)
|
||||||
|
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
spr.modeltypeid,
|
||||||
|
'Moteur',
|
||||||
|
'moteur',
|
||||||
|
spr.typepieceid,
|
||||||
|
spr.position,
|
||||||
|
spr.createdat,
|
||||||
|
:now
|
||||||
|
FROM skeleton_piece_requirements spr
|
||||||
|
WHERE spr.typepieceid = :id",
|
||||||
|
['id' => self::MODEL_TYPE_ID, 'now' => $now],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
'DELETE FROM skeleton_piece_requirements WHERE typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d skeleton requirement(s) convertis', $skelReqs));
|
||||||
|
|
||||||
|
// 2h. Mettre à jour audit_logs entity_type
|
||||||
|
$auditUpdated = $this->connection->executeStatement(
|
||||||
|
"UPDATE audit_logs SET entitytype = 'composant'
|
||||||
|
WHERE entitytype = 'piece'
|
||||||
|
AND entityid IN (SELECT id FROM pieces WHERE typepieceid = :id)",
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d audit log(s) mis à jour', $auditUpdated));
|
||||||
|
|
||||||
|
// 2i. Mettre à jour comments entity_type
|
||||||
|
$commentsUpdated = $this->connection->executeStatement(
|
||||||
|
"UPDATE comments SET entity_type = 'composant'
|
||||||
|
WHERE entity_type = 'piece'
|
||||||
|
AND entity_id IN (SELECT id FROM pieces WHERE typepieceid = :id)",
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d commentaire(s) mis à jour', $commentsUpdated));
|
||||||
|
|
||||||
|
// 2j. Supprimer les pièces originales
|
||||||
|
$deleted = $this->connection->executeStatement(
|
||||||
|
'DELETE FROM pieces WHERE typepieceid = :id',
|
||||||
|
['id' => self::MODEL_TYPE_ID],
|
||||||
|
);
|
||||||
|
$io->text(sprintf('✓ %d pièce(s) supprimée(s)', $deleted));
|
||||||
|
|
||||||
|
// 2k. Changer la catégorie du ModelType
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
"UPDATE model_types SET category = 'COMPONENT', updatedat = :now WHERE id = :id",
|
||||||
|
['id' => self::MODEL_TYPE_ID, 'now' => $now],
|
||||||
|
);
|
||||||
|
$io->text('✓ ModelType "Moteur" passé en COMPONENT');
|
||||||
|
|
||||||
|
$this->connection->commit();
|
||||||
|
|
||||||
|
$io->success(sprintf('Conversion terminée : %d pièces → composants.', $converted));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->connection->rollBack();
|
||||||
|
$io->error('Erreur — rollback effectué : '.$e->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
private function countRelatedData(): array
|
||||||
|
{
|
||||||
|
$id = self::MODEL_TYPE_ID;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pieces' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM pieces WHERE typepieceid = :id',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'documents' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM documents WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'custom_field_values' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM custom_field_values WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'custom_fields' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM custom_fields WHERE typepieceid = :id',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'piece_constructeur_links' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM piece_constructeur_links WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'composant_piece_slots (→ subcomponent_slots)' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM composant_piece_slots WHERE typepieceid = :id',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
'skeleton_piece_requirements (→ subcomponent_reqs)' => (int) $this->connection->fetchOne(
|
||||||
|
'SELECT COUNT(*) FROM skeleton_piece_requirements WHERE typepieceid = :id',
|
||||||
|
['id' => $id],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
295
src/Command/ImportFournisseursCommand.php
Normal file
295
src/Command/ImportFournisseursCommand.php
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Constructeur;
|
||||||
|
use App\Entity\ConstructeurCategorie;
|
||||||
|
use App\Entity\ConstructeurTelephone;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use SplObjectStorage;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importe un référentiel de fournisseurs depuis un fichier JSON de la forme
|
||||||
|
* {"count": N, "data": [{"reference": "...", "name": "...", "categoriesStr": "a, b", "organizationsStr": "...", "phone": "..."}, ...]}.
|
||||||
|
*
|
||||||
|
* Règles : on garde l'existant. Si un fournisseur du fichier porte le même nom (insensible à la casse/aux espaces)
|
||||||
|
* qu'un fournisseur déjà en base, on le complète sans changer son id : on n'ajoute que les catégories et les
|
||||||
|
* téléphones manquants, on n'écrase ni ne supprime jamais rien.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:import-fournisseurs',
|
||||||
|
description: 'Importe/complète les fournisseurs depuis un fichier JSON (customer.json par défaut). Dry-run par défaut : utiliser --force pour écrire.',
|
||||||
|
)]
|
||||||
|
class ImportFournisseursCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
#[Autowire('%kernel.project_dir%')]
|
||||||
|
private readonly string $projectDir,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addArgument('file', InputArgument::OPTIONAL, 'Chemin du fichier JSON', 'customer.json')
|
||||||
|
->addOption('force', null, InputOption::VALUE_NONE, 'Écrit réellement en base (sinon dry-run)')
|
||||||
|
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Ne traiter que les N premières entrées (debug)')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$write = (bool) $input->getOption('force');
|
||||||
|
$limit = null !== $input->getOption('limit') ? max(0, (int) $input->getOption('limit')) : null;
|
||||||
|
|
||||||
|
$path = (string) $input->getArgument('file');
|
||||||
|
if (!str_starts_with($path, '/')) {
|
||||||
|
$path = rtrim($this->projectDir, '/').'/'.$path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file($path) || !is_readable($path)) {
|
||||||
|
$io->error(sprintf('Fichier introuvable ou illisible : %s', $path));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
$decoded = json_decode((string) $raw, true);
|
||||||
|
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
|
||||||
|
$io->error('JSON invalide : la clé "data" (tableau) est attendue.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int, array<string, mixed>> $rows */
|
||||||
|
$rows = $decoded['data'];
|
||||||
|
if (null !== $limit) {
|
||||||
|
$rows = array_slice($rows, 0, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->title('Import fournisseurs');
|
||||||
|
$io->writeln(sprintf('Fichier : <info>%s</info>', $path));
|
||||||
|
$io->writeln(sprintf('Entrées : <info>%d</info>', count($rows)));
|
||||||
|
$io->writeln($write ? '<comment>Mode écriture (--force)</comment>' : '<comment>Mode dry-run — aucune écriture. Ajouter --force pour appliquer.</comment>');
|
||||||
|
$io->newLine();
|
||||||
|
|
||||||
|
// --- Chargement des référentiels existants ---------------------------------
|
||||||
|
/** @var array<string, Constructeur> $constructeursByName */
|
||||||
|
$constructeursByName = [];
|
||||||
|
foreach ($this->em->getRepository(Constructeur::class)->findAll() as $c) {
|
||||||
|
$constructeursByName[$this->normalizeKey((string) $c->getName())] = $c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, ConstructeurCategorie> $categoriesByName */
|
||||||
|
$categoriesByName = [];
|
||||||
|
foreach ($this->em->getRepository(ConstructeurCategorie::class)->findAll() as $cat) {
|
||||||
|
$categoriesByName[$this->normalizeKey((string) $cat->getName())] = $cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// numéros et liens catégorie déjà présents, indexés par objet Constructeur
|
||||||
|
$seenNumeros = new SplObjectStorage(); // Constructeur => array<string,true> (clé = numéro normalisé)
|
||||||
|
$seenCatLinks = new SplObjectStorage(); // Constructeur => array<string,true> (clé = nom catégorie normalisé)
|
||||||
|
|
||||||
|
// pré-remplissage pour les fournisseurs existants
|
||||||
|
$existingTel = $this->em->getRepository(ConstructeurTelephone::class)->findAll();
|
||||||
|
foreach ($existingTel as $tel) {
|
||||||
|
$owner = $tel->getConstructeur();
|
||||||
|
if (null === $owner) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map = $seenNumeros[$owner] ?? [];
|
||||||
|
$map[$this->normalizeKey((string) $tel->getNumero())] = true;
|
||||||
|
$seenNumeros[$owner] = $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int, array{cname: string, catname: string}> $catLinkPairs */
|
||||||
|
$catLinkPairs = $this->em->createQuery(
|
||||||
|
'SELECT c.name AS cname, cat.name AS catname FROM '.Constructeur::class.' c JOIN c.categories cat'
|
||||||
|
)->getArrayResult();
|
||||||
|
foreach ($catLinkPairs as $pair) {
|
||||||
|
$cKey = $this->normalizeKey((string) $pair['cname']);
|
||||||
|
$catKey = $this->normalizeKey((string) $pair['catname']);
|
||||||
|
$owner = $constructeursByName[$cKey] ?? null;
|
||||||
|
if (null === $owner) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map = $seenCatLinks[$owner] ?? [];
|
||||||
|
$map[$catKey] = true;
|
||||||
|
$seenCatLinks[$owner] = $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Traitement ------------------------------------------------------------
|
||||||
|
$created = 0;
|
||||||
|
$matched = 0;
|
||||||
|
$phonesAdded = 0;
|
||||||
|
$categoriesCreated = 0;
|
||||||
|
$catLinksAdded = 0;
|
||||||
|
$skippedNoName = 0;
|
||||||
|
$tooLong = [];
|
||||||
|
|
||||||
|
$i = 0;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
++$i;
|
||||||
|
$name = trim((string) ($row['name'] ?? $row['reference'] ?? ''));
|
||||||
|
if ('' === $name) {
|
||||||
|
++$skippedNoName;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (mb_strlen($name) > 255) {
|
||||||
|
$tooLong[] = $name;
|
||||||
|
$name = mb_substr($name, 0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $this->normalizeKey($name);
|
||||||
|
if (isset($constructeursByName[$key])) {
|
||||||
|
$constructeur = $constructeursByName[$key];
|
||||||
|
++$matched;
|
||||||
|
} else {
|
||||||
|
$constructeur = new Constructeur()->setName($name);
|
||||||
|
if ($write) {
|
||||||
|
$this->em->persist($constructeur);
|
||||||
|
}
|
||||||
|
$constructeursByName[$key] = $constructeur;
|
||||||
|
++$created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- téléphones ---
|
||||||
|
foreach ($this->splitPhones((string) ($row['phone'] ?? '')) as $numero) {
|
||||||
|
$numero = mb_substr($numero, 0, 50);
|
||||||
|
$nKey = $this->normalizeKey($numero);
|
||||||
|
$map = $seenNumeros[$constructeur] ?? [];
|
||||||
|
if (isset($map[$nKey])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$tel = new ConstructeurTelephone()->setNumero($numero);
|
||||||
|
$constructeur->addTelephone($tel);
|
||||||
|
if ($write) {
|
||||||
|
$this->em->persist($tel);
|
||||||
|
}
|
||||||
|
$map[$nKey] = true;
|
||||||
|
$seenNumeros[$constructeur] = $map;
|
||||||
|
++$phonesAdded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- catégories ---
|
||||||
|
foreach ($this->splitCategories((string) ($row['categoriesStr'] ?? '')) as $catName) {
|
||||||
|
$catName = mb_substr($catName, 0, 255);
|
||||||
|
$catKey = $this->normalizeKey($catName);
|
||||||
|
if (isset($categoriesByName[$catKey])) {
|
||||||
|
$categorie = $categoriesByName[$catKey];
|
||||||
|
} else {
|
||||||
|
$categorie = new ConstructeurCategorie()->setName($catName);
|
||||||
|
if ($write) {
|
||||||
|
$this->em->persist($categorie);
|
||||||
|
}
|
||||||
|
$categoriesByName[$catKey] = $categorie;
|
||||||
|
++$categoriesCreated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkMap = $seenCatLinks[$constructeur] ?? [];
|
||||||
|
if (isset($linkMap[$catKey])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$constructeur->addCategory($categorie);
|
||||||
|
$linkMap[$catKey] = true;
|
||||||
|
$seenCatLinks[$constructeur] = $linkMap;
|
||||||
|
++$catLinksAdded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($write && 0 === $i % 200) {
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($write) {
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rapport ---------------------------------------------------------------
|
||||||
|
$io->section('Résultat');
|
||||||
|
$io->table(
|
||||||
|
['Action', 'Nombre'],
|
||||||
|
[
|
||||||
|
['Fournisseurs créés', $created],
|
||||||
|
['Fournisseurs déjà en base (complétés si besoin)', $matched],
|
||||||
|
['Téléphones ajoutés', $phonesAdded],
|
||||||
|
['Catégories créées', $categoriesCreated],
|
||||||
|
['Liens fournisseur↔catégorie ajoutés', $catLinksAdded],
|
||||||
|
['Entrées ignorées (sans nom)', $skippedNoName],
|
||||||
|
['Noms tronqués (>255)', count($tooLong)],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($tooLong) {
|
||||||
|
$io->warning(sprintf('%d nom(s) dépassaient 255 caractères et ont été tronqués.', count($tooLong)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($write) {
|
||||||
|
$io->success('Import terminé.');
|
||||||
|
} else {
|
||||||
|
$io->note('Dry-run : rien n\'a été écrit. Relancer avec --force pour appliquer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function splitPhones(string $value): array
|
||||||
|
{
|
||||||
|
$parts = preg_split('#[/;\n\r]+#', $value) ?: [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($parts as $p) {
|
||||||
|
$p = trim($p);
|
||||||
|
if ('' !== $p) {
|
||||||
|
$out[] = $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($out));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function splitCategories(string $value): array
|
||||||
|
{
|
||||||
|
$parts = explode(',', $value);
|
||||||
|
$out = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($parts as $p) {
|
||||||
|
$p = trim($p);
|
||||||
|
if ('' === $p) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$k = $this->normalizeKey($p);
|
||||||
|
if (isset($seen[$k])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$k] = true;
|
||||||
|
$out[] = $p;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeKey(string $value): string
|
||||||
|
{
|
||||||
|
return mb_strtolower(trim(preg_replace('/\s+/u', ' ', $value) ?? $value));
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Controller/CustomFieldNamesController.php
Normal file
37
src/Controller/CustomFieldNamesController.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\Composant;
|
use App\Entity\Composant;
|
||||||
|
use App\Entity\Constructeur;
|
||||||
use App\Entity\CustomField;
|
use App\Entity\CustomField;
|
||||||
use App\Entity\CustomFieldValue;
|
use App\Entity\CustomFieldValue;
|
||||||
use App\Entity\Machine;
|
use App\Entity\Machine;
|
||||||
@@ -162,9 +163,6 @@ class MachineStructureController extends AbstractController
|
|||||||
// Copy product links
|
// Copy product links
|
||||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
||||||
|
|
||||||
// Copy context field values
|
|
||||||
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||||
@@ -230,6 +228,17 @@ class MachineStructureController extends AbstractController
|
|||||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||||
$newLink->setPrixOverride($link->getPrixOverride());
|
$newLink->setPrixOverride($link->getPrixOverride());
|
||||||
$this->entityManager->persist($newLink);
|
$this->entityManager->persist($newLink);
|
||||||
|
|
||||||
|
foreach ($link->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);
|
||||||
|
$newLink->getContextFieldValues()->add($newValue);
|
||||||
|
}
|
||||||
|
|
||||||
$linkMap[$link->getId()] = $newLink;
|
$linkMap[$link->getId()] = $newLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +278,17 @@ class MachineStructureController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->entityManager->persist($newLink);
|
$this->entityManager->persist($newLink);
|
||||||
|
|
||||||
|
foreach ($link->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);
|
||||||
|
$newLink->getContextFieldValues()->add($newValue);
|
||||||
|
}
|
||||||
|
|
||||||
$linkMap[$link->getId()] = $newLink;
|
$linkMap[$link->getId()] = $newLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,45 +337,6 @@ class MachineStructureController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizePayloadList(mixed $value): array
|
private function normalizePayloadList(mixed $value): array
|
||||||
{
|
{
|
||||||
if (!is_array($value)) {
|
if (!is_array($value)) {
|
||||||
@@ -892,7 +873,7 @@ class MachineStructureController extends AbstractController
|
|||||||
'id' => $link->getConstructeur()->getId(),
|
'id' => $link->getConstructeur()->getId(),
|
||||||
'name' => $link->getConstructeur()->getName(),
|
'name' => $link->getConstructeur()->getName(),
|
||||||
'email' => $link->getConstructeur()->getEmail(),
|
'email' => $link->getConstructeur()->getEmail(),
|
||||||
'phone' => $link->getConstructeur()->getPhone(),
|
'phone' => $this->constructeurPhone($link->getConstructeur()),
|
||||||
],
|
],
|
||||||
'supplierReference' => $link->getSupplierReference(),
|
'supplierReference' => $link->getSupplierReference(),
|
||||||
];
|
];
|
||||||
@@ -901,6 +882,13 @@ class MachineStructureController extends AbstractController
|
|||||||
return $items;
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function constructeurPhone(Constructeur $constructeur): ?string
|
||||||
|
{
|
||||||
|
$first = $constructeur->getTelephones()->first();
|
||||||
|
|
||||||
|
return false !== $first ? $first->getNumero() : null;
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeCustomFieldDefinitions(Collection $customFields): array
|
private function normalizeCustomFieldDefinitions(Collection $customFields): array
|
||||||
{
|
{
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
@@ -12,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\Metadata\Put;
|
use ApiPlatform\Metadata\Put;
|
||||||
use App\Entity\Trait\CuidEntityTrait;
|
use App\Entity\Trait\CuidEntityTrait;
|
||||||
|
use App\Filter\ConstructeurSearchFilter;
|
||||||
use App\Repository\ConstructeurRepository;
|
use App\Repository\ConstructeurRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -19,12 +23,16 @@ use Doctrine\Common\Collections\Collection;
|
|||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
|
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
|
||||||
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
|
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
|
||||||
#[ORM\Table(name: 'constructeurs')]
|
#[ORM\Table(name: 'constructeurs')]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
|
#[ApiFilter(ConstructeurSearchFilter::class)]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['categories.id' => 'exact'])]
|
||||||
|
#[ApiFilter(OrderFilter::class, properties: ['name', 'email', 'createdAt'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
|
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
|
||||||
operations: [
|
operations: [
|
||||||
@@ -36,7 +44,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
],
|
],
|
||||||
paginationClientItemsPerPage: true,
|
paginationClientItemsPerPage: true,
|
||||||
paginationMaximumItemsPerPage: 200
|
paginationMaximumItemsPerPage: 2000,
|
||||||
|
normalizationContext: ['groups' => ['constructeur:read']],
|
||||||
|
denormalizationContext: ['groups' => ['constructeur:write']]
|
||||||
)]
|
)]
|
||||||
class Constructeur
|
class Constructeur
|
||||||
{
|
{
|
||||||
@@ -44,24 +54,43 @@ class Constructeur
|
|||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||||
|
#[Groups(['constructeur:read'])]
|
||||||
private ?string $id = null;
|
private ?string $id = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||||
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
|
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
|
||||||
|
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||||
private ?string $name = null;
|
private ?string $name = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||||
|
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||||
private ?string $email = null;
|
private ?string $email = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
|
||||||
private ?string $phone = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||||
|
#[Groups(['constructeur:read'])]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||||
|
#[Groups(['constructeur:read'])]
|
||||||
private DateTimeImmutable $updatedAt;
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, ConstructeurTelephone>
|
||||||
|
*/
|
||||||
|
#[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: ConstructeurTelephone::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
|
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||||
|
private Collection $telephones;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, ConstructeurCategorie>
|
||||||
|
*/
|
||||||
|
#[ORM\ManyToMany(targetEntity: ConstructeurCategorie::class, inversedBy: 'constructeurs')]
|
||||||
|
#[ORM\JoinTable(name: 'constructeur_categories')]
|
||||||
|
#[ORM\JoinColumn(name: 'constructeur_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||||
|
#[ORM\InverseJoinColumn(name: 'categorie_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||||
|
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||||
|
private Collection $categories;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, MachineConstructeurLink>
|
* @var Collection<int, MachineConstructeurLink>
|
||||||
*/
|
*/
|
||||||
@@ -94,6 +123,8 @@ class Constructeur
|
|||||||
$this->composantLinks = new ArrayCollection();
|
$this->composantLinks = new ArrayCollection();
|
||||||
$this->pieceLinks = new ArrayCollection();
|
$this->pieceLinks = new ArrayCollection();
|
||||||
$this->productLinks = new ArrayCollection();
|
$this->productLinks = new ArrayCollection();
|
||||||
|
$this->telephones = new ArrayCollection();
|
||||||
|
$this->categories = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getName(): ?string
|
public function getName(): ?string
|
||||||
@@ -120,14 +151,55 @@ class Constructeur
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPhone(): ?string
|
/**
|
||||||
|
* @return Collection<int, ConstructeurTelephone>
|
||||||
|
*/
|
||||||
|
public function getTelephones(): Collection
|
||||||
{
|
{
|
||||||
return $this->phone;
|
return $this->telephones;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setPhone(?string $phone): static
|
public function addTelephone(ConstructeurTelephone $telephone): static
|
||||||
{
|
{
|
||||||
$this->phone = $phone;
|
if (!$this->telephones->contains($telephone)) {
|
||||||
|
$this->telephones->add($telephone);
|
||||||
|
$telephone->setConstructeur($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeTelephone(ConstructeurTelephone $telephone): static
|
||||||
|
{
|
||||||
|
if ($this->telephones->removeElement($telephone)) {
|
||||||
|
if ($telephone->getConstructeur() === $this) {
|
||||||
|
$telephone->setConstructeur(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, ConstructeurCategorie>
|
||||||
|
*/
|
||||||
|
public function getCategories(): Collection
|
||||||
|
{
|
||||||
|
return $this->categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addCategory(ConstructeurCategorie $category): static
|
||||||
|
{
|
||||||
|
if (!$this->categories->contains($category)) {
|
||||||
|
$this->categories->add($category);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeCategory(ConstructeurCategorie $category): static
|
||||||
|
{
|
||||||
|
$this->categories->removeElement($category);
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
92
src/Entity/ConstructeurCategorie.php
Normal file
92
src/Entity/ConstructeurCategorie.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use ApiPlatform\Metadata\Put;
|
||||||
|
use App\Entity\Trait\CuidEntityTrait;
|
||||||
|
use App\Repository\ConstructeurCategorieRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[UniqueEntity(fields: ['name'], message: 'Une catégorie de fournisseur avec ce nom existe déjà.')]
|
||||||
|
#[ORM\Entity(repositoryClass: ConstructeurCategorieRepository::class)]
|
||||||
|
#[ORM\Table(name: 'constructeur_categorie')]
|
||||||
|
#[ORM\HasLifecycleCallbacks]
|
||||||
|
#[ApiResource(
|
||||||
|
description: 'Catégories de fournisseurs (ex. organisme de formation, transporteur, agence d\'intérim). Référentiel partagé : une même catégorie peut être rattachée à plusieurs fournisseurs.',
|
||||||
|
operations: [
|
||||||
|
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||||
|
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||||
|
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
|
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
|
],
|
||||||
|
paginationClientItemsPerPage: true,
|
||||||
|
paginationMaximumItemsPerPage: 1000,
|
||||||
|
order: ['name' => 'ASC']
|
||||||
|
)]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
|
||||||
|
#[ApiFilter(OrderFilter::class, properties: ['name'])]
|
||||||
|
class ConstructeurCategorie
|
||||||
|
{
|
||||||
|
use CuidEntityTrait;
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||||
|
#[Groups(['constructeur:read'])]
|
||||||
|
private ?string $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||||
|
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
|
||||||
|
#[Groups(['constructeur:read'])]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, Constructeur>
|
||||||
|
*/
|
||||||
|
#[ORM\ManyToMany(targetEntity: Constructeur::class, mappedBy: 'categories')]
|
||||||
|
private Collection $constructeurs;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new DateTimeImmutable();
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
$this->constructeurs = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/Entity/ConstructeurTelephone.php
Normal file
109
src/Entity/ConstructeurTelephone.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use ApiPlatform\Metadata\Put;
|
||||||
|
use App\Entity\Trait\CuidEntityTrait;
|
||||||
|
use App\Repository\ConstructeurTelephoneRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: ConstructeurTelephoneRepository::class)]
|
||||||
|
#[ORM\Table(name: 'constructeur_telephone')]
|
||||||
|
#[ORM\Index(name: 'idx_constructeur_telephone_constructeur', columns: ['constructeurid'])]
|
||||||
|
#[ORM\HasLifecycleCallbacks]
|
||||||
|
#[ApiResource(
|
||||||
|
description: 'Numéro de téléphone rattaché à un fournisseur. Un fournisseur peut en avoir plusieurs (standard, mobile, comptabilité…).',
|
||||||
|
operations: [
|
||||||
|
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||||
|
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||||
|
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
|
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
|
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['constructeur' => 'exact'])]
|
||||||
|
class ConstructeurTelephone
|
||||||
|
{
|
||||||
|
use CuidEntityTrait;
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||||
|
#[Groups(['constructeur:read'])]
|
||||||
|
private ?string $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Constructeur::class, inversedBy: 'telephones')]
|
||||||
|
#[ORM\JoinColumn(name: 'constructeurId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Constructeur $constructeur = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::STRING, length: 50)]
|
||||||
|
#[Assert\NotBlank(message: 'Le numéro de téléphone est obligatoire.')]
|
||||||
|
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||||
|
private ?string $numero = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
|
||||||
|
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||||
|
private ?string $label = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new DateTimeImmutable();
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConstructeur(): ?Constructeur
|
||||||
|
{
|
||||||
|
return $this->constructeur;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setConstructeur(?Constructeur $constructeur): static
|
||||||
|
{
|
||||||
|
$this->constructeur = $constructeur;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNumero(): ?string
|
||||||
|
{
|
||||||
|
return $this->numero;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNumero(string $numero): static
|
||||||
|
{
|
||||||
|
$this->numero = $numero;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(?string $label): static
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ use App\Entity\Machine;
|
|||||||
use App\Entity\ModelType;
|
use App\Entity\ModelType;
|
||||||
use App\Entity\Piece;
|
use App\Entity\Piece;
|
||||||
use App\Entity\Product;
|
use App\Entity\Product;
|
||||||
use App\Entity\Profile;
|
|
||||||
use App\Entity\Site;
|
use App\Entity\Site;
|
||||||
|
use App\Service\ActorProfileResolver;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
@@ -22,10 +22,6 @@ use Doctrine\ORM\Event\OnFlushEventArgs;
|
|||||||
use Doctrine\ORM\Events;
|
use Doctrine\ORM\Events;
|
||||||
use Doctrine\ORM\UnitOfWork;
|
use Doctrine\ORM\UnitOfWork;
|
||||||
use Error;
|
use Error;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
use function is_array;
|
use function is_array;
|
||||||
use function is_object;
|
use function is_object;
|
||||||
@@ -35,8 +31,7 @@ use function method_exists;
|
|||||||
abstract class AbstractAuditSubscriber implements EventSubscriber
|
abstract class AbstractAuditSubscriber implements EventSubscriber
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RequestStack $requestStack,
|
protected readonly ActorProfileResolver $actorProfileResolver,
|
||||||
private readonly Security $security,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getSubscribedEvents(): array
|
public function getSubscribedEvents(): array
|
||||||
@@ -61,7 +56,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$actorProfileId = $this->resolveActorProfileId();
|
$actorProfileId = $this->actorProfileResolver->resolve();
|
||||||
$entityType = $this->entityType();
|
$entityType = $this->entityType();
|
||||||
|
|
||||||
if ($this->hasCollectionTracking()) {
|
if ($this->hasCollectionTracking()) {
|
||||||
@@ -278,28 +273,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
|||||||
return $entity->getVersion();
|
return $entity->getVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveActorProfileId(): ?string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$session = $this->requestStack->getSession();
|
|
||||||
if ($session instanceof SessionInterface) {
|
|
||||||
$profileId = $session->get('profileId');
|
|
||||||
if ($profileId) {
|
|
||||||
return (string) $profileId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Throwable) {
|
|
||||||
// No session available (CLI context, etc.)
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if ($user instanceof Profile) {
|
|
||||||
return $user->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
|
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
|
||||||
{
|
{
|
||||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||||
@@ -385,13 +358,10 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
|||||||
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
// Note: scheduled collection updates/deletions are intentionally not
|
||||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
// tracked here — constructeurs are now persisted as ConstructeurLink
|
||||||
}
|
// entities (OneToMany), so Doctrine no longer fires collection events
|
||||||
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
// for them. Custom field values are handled below.
|
||||||
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
|
||||||
|
|
||||||
foreach ($pendingUpdates as $entityId => $diff) {
|
foreach ($pendingUpdates as $entityId => $diff) {
|
||||||
@@ -411,17 +381,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* No-op: constructeurs are now tracked as ConstructeurLink entities (OneToMany),
|
|
||||||
* so Doctrine no longer fires collection update events for them.
|
|
||||||
*/
|
|
||||||
private function collectCollectionUpdate(
|
|
||||||
object $collection,
|
|
||||||
array &$pendingUpdates,
|
|
||||||
array &$pendingSnapshots,
|
|
||||||
array &$pendingEntities,
|
|
||||||
): void {}
|
|
||||||
|
|
||||||
private function collectCustomFieldValueChanges(
|
private function collectCustomFieldValueChanges(
|
||||||
UnitOfWork $uow,
|
UnitOfWork $uow,
|
||||||
array &$pendingUpdates,
|
array &$pendingUpdates,
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace App\EventSubscriber;
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
use App\Entity\Constructeur;
|
use App\Entity\Constructeur;
|
||||||
|
use App\Entity\ConstructeurCategorie;
|
||||||
|
use App\Entity\ConstructeurTelephone;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Events;
|
use Doctrine\ORM\Events;
|
||||||
|
|
||||||
#[AsDoctrineListener(event: Events::onFlush)]
|
#[AsDoctrineListener(event: Events::onFlush)]
|
||||||
@@ -23,11 +26,21 @@ final class ConstructeurAuditSubscriber extends AbstractAuditSubscriber
|
|||||||
|
|
||||||
protected function snapshotEntity(object $entity): array
|
protected function snapshotEntity(object $entity): array
|
||||||
{
|
{
|
||||||
|
$telephones = $this->safeGet($entity, 'getTelephones');
|
||||||
|
$categories = $this->safeGet($entity, 'getCategories');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $entity->getId(),
|
'id' => $entity->getId(),
|
||||||
'name' => $this->safeGet($entity, 'getName'),
|
'name' => $this->safeGet($entity, 'getName'),
|
||||||
'email' => $this->safeGet($entity, 'getEmail'),
|
'email' => $this->safeGet($entity, 'getEmail'),
|
||||||
'phone' => $this->safeGet($entity, 'getPhone'),
|
'telephones' => $telephones instanceof Collection ? array_values(array_map(
|
||||||
|
static fn (ConstructeurTelephone $t): array => ['numero' => $t->getNumero(), 'label' => $t->getLabel()],
|
||||||
|
$telephones->toArray(),
|
||||||
|
)) : [],
|
||||||
|
'categories' => $categories instanceof Collection ? array_values(array_filter(array_map(
|
||||||
|
static fn (ConstructeurCategorie $c): ?string => $c->getName(),
|
||||||
|
$categories->toArray(),
|
||||||
|
))) : [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
|||||||
}
|
}
|
||||||
|
|
||||||
$uow = $em->getUnitOfWork();
|
$uow = $em->getUnitOfWork();
|
||||||
$actorProfileId = $this->resolveActorProfileId();
|
$actorProfileId = $this->actorProfileResolver->resolve();
|
||||||
|
|
||||||
$this->processLinkChanges($em, $uow, $actorProfileId);
|
$this->processLinkChanges($em, $uow, $actorProfileId);
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/EventSubscriber/ModelTypeReferenceCleanupSubscriber.php
Normal file
57
src/EventSubscriber/ModelTypeReferenceCleanupSubscriber.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use App\Entity\ModelType;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||||
|
use Doctrine\ORM\Event\PreRemoveEventArgs;
|
||||||
|
use Doctrine\ORM\Events;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Belt-and-suspenders cleanup of weak references to a ModelType before deletion:
|
||||||
|
* runs the equivalent of every "ON DELETE SET NULL" cascade applicatively, in case
|
||||||
|
* the database FK fails to fire (observed on prod in 2026-04 — the deletion of
|
||||||
|
* ModelType "Paliers" left an orphan in skeleton_subcomponent_requirements).
|
||||||
|
*/
|
||||||
|
#[AsDoctrineListener(event: Events::preRemove)]
|
||||||
|
final class ModelTypeReferenceCleanupSubscriber
|
||||||
|
{
|
||||||
|
/** @var list<array{0: string, 1: string}> */
|
||||||
|
private const NULLABLE_REFERENCES = [
|
||||||
|
['skeleton_subcomponent_requirements', 'typecomposantid'],
|
||||||
|
['skeleton_piece_requirements', 'typepieceid'],
|
||||||
|
['skeleton_product_requirements', 'typeproductid'],
|
||||||
|
['composant_piece_slots', 'typepieceid'],
|
||||||
|
['composant_product_slots', 'typeproductid'],
|
||||||
|
['composant_subcomponent_slots', 'typecomposantid'],
|
||||||
|
['piece_product_slots', 'typeproductid'],
|
||||||
|
['machine_component_links', 'modeltypeid'],
|
||||||
|
['machine_piece_links', 'modeltypeid'],
|
||||||
|
['machine_product_links', 'modeltypeid'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function preRemove(PreRemoveEventArgs $args): void
|
||||||
|
{
|
||||||
|
$entity = $args->getObject();
|
||||||
|
if (!$entity instanceof ModelType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $entity->getId();
|
||||||
|
if (!$id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn = $args->getObjectManager()->getConnection();
|
||||||
|
foreach (self::NULLABLE_REFERENCES as [$table, $column]) {
|
||||||
|
$conn->executeStatement(
|
||||||
|
sprintf('UPDATE %s SET %s = NULL WHERE %s = ?', $table, $column, $column),
|
||||||
|
[$id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/Filter/ConstructeurSearchFilter.php
Normal file
59
src/Filter/ConstructeurSearchFilter.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filter;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search filter pour Constructeur : LIKE insensible à la casse sur name, email
|
||||||
|
* + LEFT JOIN sur la collection telephones pour matcher aussi sur telephone.numero.
|
||||||
|
* Param query : ?search=...
|
||||||
|
*/
|
||||||
|
final class ConstructeurSearchFilter extends AbstractFilter
|
||||||
|
{
|
||||||
|
public function getDescription(string $resourceClass): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'search' => [
|
||||||
|
'property' => null,
|
||||||
|
'type' => 'string',
|
||||||
|
'required' => false,
|
||||||
|
'description' => 'Recherche dans le nom, l\'email et les numéros de téléphone du fournisseur.',
|
||||||
|
'openapi' => [
|
||||||
|
'allowEmptyValue' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
|
||||||
|
{
|
||||||
|
if ('search' !== $property || !is_string($value) || '' === trim($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alias = $queryBuilder->getRootAliases()[0];
|
||||||
|
$telAlias = $queryNameGenerator->generateJoinAlias('telephones');
|
||||||
|
$paramName = $queryNameGenerator->generateParameterName('search');
|
||||||
|
$likePattern = '%'.mb_strtolower(trim($value)).'%';
|
||||||
|
|
||||||
|
$queryBuilder
|
||||||
|
->leftJoin(sprintf('%s.telephones', $alias), $telAlias)
|
||||||
|
->andWhere(sprintf(
|
||||||
|
'LOWER(%1$s.name) LIKE :%4$s OR LOWER(%1$s.email) LIKE :%4$s OR LOWER(%2$s.numero) LIKE :%4$s',
|
||||||
|
$alias,
|
||||||
|
$telAlias,
|
||||||
|
'',
|
||||||
|
$paramName,
|
||||||
|
))
|
||||||
|
->setParameter($paramName, $likePattern)
|
||||||
|
;
|
||||||
|
|
||||||
|
$queryBuilder->groupBy(sprintf('%s.id', $alias));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Mcp\Tool\Constructeur;
|
namespace App\Mcp\Tool\Constructeur;
|
||||||
|
|
||||||
use App\Entity\Constructeur;
|
use App\Entity\Constructeur;
|
||||||
|
use App\Entity\ConstructeurTelephone;
|
||||||
use App\Mcp\Tool\McpToolHelper;
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Mcp\Capability\Attribute\McpTool;
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
@@ -34,7 +35,12 @@ class CreateConstructeurTool
|
|||||||
$constructeur = new Constructeur();
|
$constructeur = new Constructeur();
|
||||||
$constructeur->setName($name);
|
$constructeur->setName($name);
|
||||||
$constructeur->setEmail('' !== $email ? $email : null);
|
$constructeur->setEmail('' !== $email ? $email : null);
|
||||||
$constructeur->setPhone('' !== $phone ? $phone : null);
|
|
||||||
|
if ('' !== $phone) {
|
||||||
|
$telephone = new ConstructeurTelephone();
|
||||||
|
$telephone->setNumero($phone);
|
||||||
|
$constructeur->addTelephone($telephone);
|
||||||
|
}
|
||||||
|
|
||||||
$this->em->persist($constructeur);
|
$this->em->persist($constructeur);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|||||||
@@ -29,13 +29,23 @@ class GetConstructeurTool
|
|||||||
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
|
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$telephones = array_map(
|
||||||
|
static fn ($t): array => ['id' => $t->getId(), 'numero' => $t->getNumero(), 'label' => $t->getLabel()],
|
||||||
|
$constructeur->getTelephones()->toArray(),
|
||||||
|
);
|
||||||
|
$categories = array_values(array_filter(array_map(
|
||||||
|
static fn ($c): ?string => $c->getName(),
|
||||||
|
$constructeur->getCategories()->toArray(),
|
||||||
|
)));
|
||||||
|
|
||||||
return $this->jsonResponse([
|
return $this->jsonResponse([
|
||||||
'id' => $constructeur->getId(),
|
'id' => $constructeur->getId(),
|
||||||
'name' => $constructeur->getName(),
|
'name' => $constructeur->getName(),
|
||||||
'email' => $constructeur->getEmail(),
|
'email' => $constructeur->getEmail(),
|
||||||
'phone' => $constructeur->getPhone(),
|
'telephones' => array_values($telephones),
|
||||||
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
|
'categories' => $categories,
|
||||||
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
|
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class ListConstructeursTool
|
|||||||
;
|
;
|
||||||
|
|
||||||
$qb = $this->constructeurs->createQueryBuilder('c')
|
$qb = $this->constructeurs->createQueryBuilder('c')
|
||||||
->select('c.id', 'c.name', 'c.email', 'c.phone')
|
->select('c.id', 'c.name', 'c.email')
|
||||||
->orderBy('c.name', 'ASC')
|
->orderBy('c.name', 'ASC')
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Mcp\Tool\Constructeur;
|
namespace App\Mcp\Tool\Constructeur;
|
||||||
|
|
||||||
|
use App\Entity\ConstructeurTelephone;
|
||||||
use App\Mcp\Tool\McpToolHelper;
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
use App\Repository\ConstructeurRepository;
|
use App\Repository\ConstructeurRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -13,7 +14,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
|||||||
|
|
||||||
#[McpTool(
|
#[McpTool(
|
||||||
name: 'update_constructeur',
|
name: 'update_constructeur',
|
||||||
description: 'Update an existing constructeur. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
|
description: 'Update an existing constructeur. Only provided fields are changed. A non-empty "phone" is added as an additional phone number if not already present (existing numbers are never removed). Requires ROLE_GESTIONNAIRE.',
|
||||||
)]
|
)]
|
||||||
class UpdateConstructeurTool
|
class UpdateConstructeurTool
|
||||||
{
|
{
|
||||||
@@ -45,8 +46,20 @@ class UpdateConstructeurTool
|
|||||||
if (null !== $email) {
|
if (null !== $email) {
|
||||||
$constructeur->setEmail($email);
|
$constructeur->setEmail($email);
|
||||||
}
|
}
|
||||||
if (null !== $phone) {
|
if (null !== $phone && '' !== $phone) {
|
||||||
$constructeur->setPhone($phone);
|
$alreadyPresent = false;
|
||||||
|
foreach ($constructeur->getTelephones() as $existing) {
|
||||||
|
if ($existing->getNumero() === $phone) {
|
||||||
|
$alreadyPresent = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$alreadyPresent) {
|
||||||
|
$telephone = new ConstructeurTelephone();
|
||||||
|
$telephone->setNumero($phone);
|
||||||
|
$constructeur->addTelephone($telephone);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Mcp\Tool\Machine;
|
namespace App\Mcp\Tool\Machine;
|
||||||
|
|
||||||
use App\Entity\Composant;
|
use App\Entity\Composant;
|
||||||
|
use App\Entity\Constructeur;
|
||||||
use App\Entity\CustomField;
|
use App\Entity\CustomField;
|
||||||
use App\Entity\CustomFieldValue;
|
use App\Entity\CustomFieldValue;
|
||||||
use App\Entity\Machine;
|
use App\Entity\Machine;
|
||||||
@@ -364,7 +365,7 @@ class MachineStructureTool
|
|||||||
'id' => $link->getConstructeur()->getId(),
|
'id' => $link->getConstructeur()->getId(),
|
||||||
'name' => $link->getConstructeur()->getName(),
|
'name' => $link->getConstructeur()->getName(),
|
||||||
'email' => $link->getConstructeur()->getEmail(),
|
'email' => $link->getConstructeur()->getEmail(),
|
||||||
'phone' => $link->getConstructeur()->getPhone(),
|
'phone' => $this->constructeurPhone($link->getConstructeur()),
|
||||||
],
|
],
|
||||||
'supplierReference' => $link->getSupplierReference(),
|
'supplierReference' => $link->getSupplierReference(),
|
||||||
];
|
];
|
||||||
@@ -373,6 +374,13 @@ class MachineStructureTool
|
|||||||
return $items;
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function constructeurPhone(Constructeur $constructeur): ?string
|
||||||
|
{
|
||||||
|
$first = $constructeur->getTelephones()->first();
|
||||||
|
|
||||||
|
return false !== $first ? $first->getNumero() : null;
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeCustomFields(Collection $customFields): array
|
private function normalizeCustomFields(Collection $customFields): array
|
||||||
{
|
{
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|||||||
20
src/Repository/ConstructeurCategorieRepository.php
Normal file
20
src/Repository/ConstructeurCategorieRepository.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\ConstructeurCategorie;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ConstructeurCategorie>
|
||||||
|
*/
|
||||||
|
class ConstructeurCategorieRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ConstructeurCategorie::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Repository/ConstructeurTelephoneRepository.php
Normal file
20
src/Repository/ConstructeurTelephoneRepository.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\ConstructeurTelephone;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ConstructeurTelephone>
|
||||||
|
*/
|
||||||
|
class ConstructeurTelephoneRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ConstructeurTelephone::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/Service/ActorProfileResolver.php
Normal file
41
src/Service/ActorProfileResolver.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Profile;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ActorProfileResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function resolve(): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$session = $this->requestStack->getSession();
|
||||||
|
if ($session instanceof SessionInterface) {
|
||||||
|
$profileId = $session->get('profileId');
|
||||||
|
if ($profileId) {
|
||||||
|
return (string) $profileId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
// No session available (CLI context, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if ($user instanceof Profile) {
|
||||||
|
return $user->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ use DateTimeInterface;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use LogicException;
|
use LogicException;
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class EntityVersionService
|
final class EntityVersionService
|
||||||
@@ -56,7 +55,7 @@ final class EntityVersionService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AuditLogRepository $auditLogs,
|
private readonly AuditLogRepository $auditLogs,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly ActorProfileResolver $actorProfileResolver,
|
||||||
private readonly MachineRepository $machines,
|
private readonly MachineRepository $machines,
|
||||||
private readonly ComposantRepository $composants,
|
private readonly ComposantRepository $composants,
|
||||||
private readonly PieceRepository $pieces,
|
private readonly PieceRepository $pieces,
|
||||||
@@ -187,7 +186,7 @@ final class EntityVersionService
|
|||||||
'restore',
|
'restore',
|
||||||
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
|
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
|
||||||
$this->buildCurrentSnapshot($entityType, $entity),
|
$this->buildCurrentSnapshot($entityType, $entity),
|
||||||
$this->resolveActorProfileId(),
|
$this->actorProfileResolver->resolve(),
|
||||||
$newVersion,
|
$newVersion,
|
||||||
);
|
);
|
||||||
$this->em->persist($restoreAuditLog);
|
$this->em->persist($restoreAuditLog);
|
||||||
@@ -917,25 +916,11 @@ final class EntityVersionService
|
|||||||
'position' => $slot->getPosition(),
|
'position' => $slot->getPosition(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
$snapshot['productSlots'] = [];
|
$snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
|
||||||
foreach ($entity->getProductSlots() as $slot) {
|
|
||||||
$snapshot['productSlots'][] = [
|
|
||||||
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
|
||||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
|
||||||
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('piece' === $entityType) {
|
if ('piece' === $entityType) {
|
||||||
$snapshot['productSlots'] = [];
|
$snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
|
||||||
foreach ($entity->getProductSlots() as $slot) {
|
|
||||||
$snapshot['productSlots'][] = [
|
|
||||||
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
|
|
||||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
|
||||||
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom field values
|
// Custom field values
|
||||||
@@ -953,21 +938,23 @@ final class EntityVersionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the current actor profile ID from the session.
|
* @param iterable<ComposantProductSlot|PieceProductSlot> $slots
|
||||||
* Mirrors AbstractAuditSubscriber::resolveActorProfileId().
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private function resolveActorProfileId(): ?string
|
private function serializeProductSlots(iterable $slots): array
|
||||||
{
|
{
|
||||||
try {
|
$serialized = [];
|
||||||
$session = $this->requestStack->getSession();
|
foreach ($slots as $slot) {
|
||||||
$profileId = $session->get('profileId');
|
$serialized[] = [
|
||||||
if ($profileId) {
|
'id' => $slot->getId(),
|
||||||
return (string) $profileId;
|
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||||
}
|
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||||
} catch (Throwable) {
|
'familyCode' => $slot->getFamilyCode(),
|
||||||
// No session available (CLI context, etc.)
|
'position' => $slot->getPosition(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return $serialized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,23 +4,17 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Entity\Profile;
|
|
||||||
use App\Enum\ModelCategory;
|
use App\Enum\ModelCategory;
|
||||||
use App\Repository\ModelTypeRepository;
|
use App\Repository\ModelTypeRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
final class ModelTypeCategoryConversionService
|
final class ModelTypeCategoryConversionService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Connection $connection,
|
private readonly Connection $connection,
|
||||||
private readonly ModelTypeRepository $modelTypes,
|
private readonly ModelTypeRepository $modelTypes,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly ActorProfileResolver $actorProfileResolver,
|
||||||
private readonly Security $security,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -327,17 +321,7 @@ final class ModelTypeCategoryConversionService
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 7. Update ModelType
|
// 7. Update ModelType
|
||||||
$this->connection->executeStatement(
|
$this->updateModelTypeCategory($modelTypeId, ModelCategory::COMPONENT);
|
||||||
'UPDATE model_types
|
|
||||||
SET category = :cat,
|
|
||||||
updatedat = :now
|
|
||||||
WHERE id = :id',
|
|
||||||
[
|
|
||||||
'cat' => ModelCategory::COMPONENT->value,
|
|
||||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
|
||||||
'id' => $modelTypeId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
@@ -406,19 +390,24 @@ final class ModelTypeCategoryConversionService
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 7. Update ModelType
|
// 7. Update ModelType
|
||||||
|
$this->updateModelTypeCategory($modelTypeId, ModelCategory::PIECE);
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateModelTypeCategory(string $modelTypeId, ModelCategory $category): void
|
||||||
|
{
|
||||||
$this->connection->executeStatement(
|
$this->connection->executeStatement(
|
||||||
'UPDATE model_types
|
'UPDATE model_types
|
||||||
SET category = :cat,
|
SET category = :cat,
|
||||||
updatedat = :now
|
updatedat = :now
|
||||||
WHERE id = :id',
|
WHERE id = :id',
|
||||||
[
|
[
|
||||||
'cat' => ModelCategory::PIECE->value,
|
'cat' => $category->value,
|
||||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||||
'id' => $modelTypeId,
|
'id' => $modelTypeId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return $count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -457,30 +446,9 @@ final class ModelTypeCategoryConversionService
|
|||||||
'action' => 'convert',
|
'action' => 'convert',
|
||||||
'diff' => json_encode($diff),
|
'diff' => json_encode($diff),
|
||||||
'snapshot' => json_encode($snapshot),
|
'snapshot' => json_encode($snapshot),
|
||||||
'actor' => $this->resolveActorProfileId(),
|
'actor' => $this->actorProfileResolver->resolve(),
|
||||||
'now' => $now,
|
'now' => $now,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveActorProfileId(): ?string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$session = $this->requestStack->getSession();
|
|
||||||
if ($session instanceof SessionInterface) {
|
|
||||||
$profileId = $session->get('profileId');
|
|
||||||
if ($profileId) {
|
|
||||||
return (string) $profileId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Throwable) {
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if ($user instanceof Profile) {
|
|
||||||
return $user->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class ReferenceAutoGenerator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string {
|
return preg_replace_callback('/\{([^}]+)\}/u', static function (array $matches) use ($valueMap): string {
|
||||||
return $valueMap[$matches[1]] ?? '';
|
return $valueMap[$matches[1]] ?? '';
|
||||||
}, $modelType->getReferenceFormula());
|
}, $modelType->getReferenceFormula());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,13 @@ class SkeletonStructureService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($existingField) {
|
if ($existingField) {
|
||||||
|
// Propagate rename to the parent ModelType's reference formula and required-fields list
|
||||||
|
// so existing `{oldName}` placeholders keep resolving after the field is renamed.
|
||||||
|
$oldName = $existingField->getName();
|
||||||
|
if ($oldName !== $normalized['name']) {
|
||||||
|
$this->propagateCustomFieldRename($modelType, $oldName, $normalized['name']);
|
||||||
|
}
|
||||||
|
|
||||||
// Update existing field
|
// Update existing field
|
||||||
$existingField->setName($normalized['name']);
|
$existingField->setName($normalized['name']);
|
||||||
$existingField->setType($normalized['type']);
|
$existingField->setType($normalized['type']);
|
||||||
@@ -264,6 +271,38 @@ class SkeletonStructureService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function propagateCustomFieldRename(ModelType $modelType, string $oldName, string $newName): void
|
||||||
|
{
|
||||||
|
$formula = $modelType->getReferenceFormula();
|
||||||
|
if (null !== $formula && '' !== $formula) {
|
||||||
|
$newFormula = preg_replace(
|
||||||
|
'/\{'.preg_quote($oldName, '/').'\}/',
|
||||||
|
'{'.$newName.'}',
|
||||||
|
$formula
|
||||||
|
);
|
||||||
|
if (null !== $newFormula && $newFormula !== $formula) {
|
||||||
|
$modelType->setReferenceFormula($newFormula);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$required = $modelType->getRequiredFieldsForReference();
|
||||||
|
if ($required) {
|
||||||
|
$changed = false;
|
||||||
|
$newRequired = [];
|
||||||
|
foreach ($required as $fieldName) {
|
||||||
|
if ($fieldName === $oldName) {
|
||||||
|
$newRequired[] = $newName;
|
||||||
|
$changed = true;
|
||||||
|
} else {
|
||||||
|
$newRequired[] = $fieldName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($changed) {
|
||||||
|
$modelType->setRequiredFieldsForReference($newRequired);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize frontend custom field data to a common shape.
|
* Normalize frontend custom field data to a common shape.
|
||||||
*
|
*
|
||||||
|
|||||||
12
symfony.lock
12
symfony.lock
@@ -157,6 +157,18 @@
|
|||||||
"symfony/mcp-bundle": {
|
"symfony/mcp-bundle": {
|
||||||
"version": "v0.6.0"
|
"version": "v0.6.0"
|
||||||
},
|
},
|
||||||
|
"symfony/monolog-bundle": {
|
||||||
|
"version": "4.0",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.7",
|
||||||
|
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/monolog.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfony/property-info": {
|
"symfony/property-info": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ namespace App\Tests;
|
|||||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||||
use App\Entity\Composant;
|
use App\Entity\Composant;
|
||||||
|
use App\Entity\ComposantConstructeurLink;
|
||||||
use App\Entity\ComposantPieceSlot;
|
use App\Entity\ComposantPieceSlot;
|
||||||
use App\Entity\ComposantProductSlot;
|
use App\Entity\ComposantProductSlot;
|
||||||
use App\Entity\ComposantSubcomponentSlot;
|
use App\Entity\ComposantSubcomponentSlot;
|
||||||
use App\Entity\ComposantConstructeurLink;
|
|
||||||
use App\Entity\Constructeur;
|
use App\Entity\Constructeur;
|
||||||
|
use App\Entity\ConstructeurCategorie;
|
||||||
|
use App\Entity\ConstructeurTelephone;
|
||||||
use App\Entity\CustomField;
|
use App\Entity\CustomField;
|
||||||
use App\Entity\CustomFieldValue;
|
use App\Entity\CustomFieldValue;
|
||||||
use App\Entity\Machine;
|
use App\Entity\Machine;
|
||||||
@@ -250,7 +252,12 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
|||||||
$c = new Constructeur();
|
$c = new Constructeur();
|
||||||
$c->setName($name);
|
$c->setName($name);
|
||||||
$c->setEmail($email);
|
$c->setEmail($email);
|
||||||
$c->setPhone($phone);
|
|
||||||
|
if (null !== $phone) {
|
||||||
|
$tel = new ConstructeurTelephone();
|
||||||
|
$tel->setNumero($phone);
|
||||||
|
$c->addTelephone($tel);
|
||||||
|
}
|
||||||
|
|
||||||
$em = $this->getEntityManager();
|
$em = $this->getEntityManager();
|
||||||
$em->persist($c);
|
$em->persist($c);
|
||||||
@@ -259,6 +266,32 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
|||||||
return $c;
|
return $c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function createConstructeurCategorie(string $name = 'Catégorie Test'): ConstructeurCategorie
|
||||||
|
{
|
||||||
|
$categorie = new ConstructeurCategorie();
|
||||||
|
$categorie->setName($name);
|
||||||
|
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$em->persist($categorie);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $categorie;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createConstructeurTelephone(Constructeur $constructeur, string $numero = '0102030405', ?string $label = null): ConstructeurTelephone
|
||||||
|
{
|
||||||
|
$tel = new ConstructeurTelephone();
|
||||||
|
$tel->setConstructeur($constructeur);
|
||||||
|
$tel->setNumero($numero);
|
||||||
|
$tel->setLabel($label);
|
||||||
|
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$em->persist($tel);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $tel;
|
||||||
|
}
|
||||||
|
|
||||||
protected function createMachineConstructeurLink(Machine $machine, Constructeur $constructeur, ?string $supplierReference = null): MachineConstructeurLink
|
protected function createMachineConstructeurLink(Machine $machine, Constructeur $constructeur, ?string $supplierReference = null): MachineConstructeurLink
|
||||||
{
|
{
|
||||||
$link = new MachineConstructeurLink();
|
$link = new MachineConstructeurLink();
|
||||||
@@ -467,6 +500,14 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
|||||||
$em->persist($cfv);
|
$em->persist($cfv);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
// Keep inverse-side collections in sync so identity-mapped entities reflect the new CFV.
|
||||||
|
if (null !== $machineComponentLink && !$machineComponentLink->getContextFieldValues()->contains($cfv)) {
|
||||||
|
$machineComponentLink->getContextFieldValues()->add($cfv);
|
||||||
|
}
|
||||||
|
if (null !== $machinePieceLink && !$machinePieceLink->getContextFieldValues()->contains($cfv)) {
|
||||||
|
$machinePieceLink->getContextFieldValues()->add($cfv);
|
||||||
|
}
|
||||||
|
|
||||||
return $cfv;
|
return $cfv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
tests/Api/Controller/CustomFieldNamesControllerTest.php
Normal file
56
tests/Api/Controller/CustomFieldNamesControllerTest.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Api\Controller;
|
||||||
|
|
||||||
|
use App\Tests\AbstractApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class CustomFieldNamesControllerTest extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
public function testReturns401WhenUnauthenticated(): void
|
||||||
|
{
|
||||||
|
$client = $this->createUnauthenticatedClient();
|
||||||
|
$client->request('GET', '/api/custom-fields/names');
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsArrayForAuthenticatedViewer(): void
|
||||||
|
{
|
||||||
|
$client = $this->createViewerClient();
|
||||||
|
$client->request('GET', '/api/custom-fields/names');
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertIsArray($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsDistinctSortedNames(): void
|
||||||
|
{
|
||||||
|
$machine1 = $this->createMachine('M1');
|
||||||
|
$this->createCustomField('Tension', 'text', $machine1);
|
||||||
|
$this->createCustomField('Numéro de série', 'text', $machine1);
|
||||||
|
|
||||||
|
$machine2 = $this->createMachine('M2');
|
||||||
|
$this->createCustomField('Tension', 'text', $machine2); // doublon
|
||||||
|
|
||||||
|
$client = $this->createViewerClient();
|
||||||
|
$client->request('GET', '/api/custom-fields/names');
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
|
||||||
|
$this->assertContains('Tension', $data);
|
||||||
|
$this->assertContains('Numéro de série', $data);
|
||||||
|
// Pas de doublon
|
||||||
|
$this->assertSame(count(array_unique($data)), count($data));
|
||||||
|
// Tri alpha
|
||||||
|
$sorted = $data;
|
||||||
|
sort($sorted, SORT_STRING);
|
||||||
|
$this->assertSame($sorted, $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,9 +33,9 @@ class ConstructeurTest extends AbstractApiTestCase
|
|||||||
|
|
||||||
$this->assertResponseIsSuccessful();
|
$this->assertResponseIsSuccessful();
|
||||||
$this->assertJsonContains([
|
$this->assertJsonContains([
|
||||||
'name' => 'Siemens',
|
'name' => 'Siemens',
|
||||||
'email' => 'contact@siemens.com',
|
'email' => 'contact@siemens.com',
|
||||||
'phone' => '+33123456789',
|
'telephones' => [['numero' => '+33123456789']],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,11 +78,32 @@ class ConstructeurTest extends AbstractApiTestCase
|
|||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [
|
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [
|
||||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
'json' => ['phone' => '+33987654321'],
|
'json' => ['email' => 'updated@siemens.com'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertResponseIsSuccessful();
|
$this->assertResponseIsSuccessful();
|
||||||
$this->assertJsonContains(['phone' => '+33987654321']);
|
$this->assertJsonContains(['email' => 'updated@siemens.com']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchCategories(): void
|
||||||
|
{
|
||||||
|
$c = $this->createConstructeur('Siemens');
|
||||||
|
$cat1 = $this->createConstructeurCategorie('Transporteur');
|
||||||
|
$cat2 = $this->createConstructeurCategorie('Organisme de formation');
|
||||||
|
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['categories' => [
|
||||||
|
self::iri('constructeur_categories', $cat1->getId()),
|
||||||
|
self::iri('constructeur_categories', $cat2->getId()),
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$client->request('GET', self::iri('constructeurs', $c->getId()));
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$this->assertJsonContains(['categories' => [['name' => 'Transporteur'], ['name' => 'Organisme de formation']]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDelete(): void
|
public function testDelete(): void
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace App\Tests\Api\Entity;
|
|||||||
use App\Enum\ModelCategory;
|
use App\Enum\ModelCategory;
|
||||||
use App\Tests\AbstractApiTestCase;
|
use App\Tests\AbstractApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
class MachineContextCustomFieldTest extends AbstractApiTestCase
|
class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||||
{
|
{
|
||||||
public function testStructureReturnsContextFieldsOnComponentLink(): void
|
public function testStructureReturnsContextFieldsOnComponentLink(): void
|
||||||
@@ -56,7 +59,7 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
|
|
||||||
$normalFields = array_filter(
|
$normalFields = array_filter(
|
||||||
$componentLink['composant']['customFields'],
|
$componentLink['composant']['customFields'],
|
||||||
fn (array $f) => $f['name'] === 'Serial',
|
fn (array $f) => 'Serial' === $f['name'],
|
||||||
);
|
);
|
||||||
$this->assertCount(1, $normalFields);
|
$this->assertCount(1, $normalFields);
|
||||||
}
|
}
|
||||||
@@ -65,8 +68,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
$site = $this->createSite('Site B');
|
$site = $this->createSite('Site B');
|
||||||
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
|
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
|
||||||
$contextField = $this->createCustomField(
|
$contextField = $this->createCustomField(
|
||||||
name: 'Wear Level',
|
name: 'Wear Level',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
@@ -101,8 +104,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
$site = $this->createSite('Site C');
|
$site = $this->createSite('Site C');
|
||||||
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
|
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
|
||||||
$contextField = $this->createCustomField(
|
$contextField = $this->createCustomField(
|
||||||
name: 'Flow Rate',
|
name: 'Flow Rate',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -131,8 +134,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
$site = $this->createSite('Site D');
|
$site = $this->createSite('Site D');
|
||||||
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
|
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
|
||||||
$contextField = $this->createCustomField(
|
$contextField = $this->createCustomField(
|
||||||
name: 'Pressure',
|
name: 'Pressure',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -171,8 +174,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
$site = $this->createSite('Site E');
|
$site = $this->createSite('Site E');
|
||||||
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
|
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
|
||||||
$contextField = $this->createCustomField(
|
$contextField = $this->createCustomField(
|
||||||
name: 'Calibration Date',
|
name: 'Calibration Date',
|
||||||
type: 'date',
|
type: 'date',
|
||||||
@@ -190,8 +193,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->createGestionnaireClient();
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
$site = $this->createSite('Site F');
|
$site = $this->createSite('Site F');
|
||||||
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
|
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
|
||||||
$contextField = $this->createCustomField(
|
$contextField = $this->createCustomField(
|
||||||
name: 'RPM Setting',
|
name: 'RPM Setting',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -225,4 +228,84 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
||||||
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
|
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCloneMachineCopiesPieceContextFieldValues(): void
|
||||||
|
{
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
|
$site = $this->createSite('Site G');
|
||||||
|
$modelType = $this->createModelType('Bearing Clone', 'BRGC', ModelCategory::PIECE);
|
||||||
|
$contextField = $this->createCustomField(
|
||||||
|
name: 'Wear Level',
|
||||||
|
type: 'text',
|
||||||
|
typePiece: $modelType,
|
||||||
|
machineContextOnly: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$source = $this->createMachine('Source Piece Machine', $site);
|
||||||
|
$piece = $this->createPiece('Bearing C', 'BRGC-001', $modelType);
|
||||||
|
$link = $this->createMachinePieceLink($source, $piece);
|
||||||
|
|
||||||
|
$this->createCustomFieldValue(
|
||||||
|
customField: $contextField,
|
||||||
|
value: 'Fair',
|
||||||
|
piece: $piece,
|
||||||
|
machinePieceLink: $link,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||||
|
'json' => [
|
||||||
|
'name' => 'Cloned Piece Machine',
|
||||||
|
'siteId' => $site->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(201);
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
$clonedLink = $data['pieceLinks'][0] ?? null;
|
||||||
|
$this->assertNotNull($clonedLink, 'Clone should expose at least one pieceLink');
|
||||||
|
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
||||||
|
$this->assertSame('Fair', $clonedLink['contextCustomFieldValues'][0]['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCloneMachineLeavesSourceContextFieldValuesIntact(): void
|
||||||
|
{
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
|
$site = $this->createSite('Site H');
|
||||||
|
$modelType = $this->createModelType('Motor Source', 'MOTS', ModelCategory::COMPONENT);
|
||||||
|
$contextField = $this->createCustomField(
|
||||||
|
name: 'RPM',
|
||||||
|
type: 'number',
|
||||||
|
typeComposant: $modelType,
|
||||||
|
machineContextOnly: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$source = $this->createMachine('Original Machine', $site);
|
||||||
|
$composant = $this->createComposant('Motor S', 'MOTS-001', $modelType);
|
||||||
|
$link = $this->createMachineComponentLink($source, $composant);
|
||||||
|
|
||||||
|
$this->createCustomFieldValue(
|
||||||
|
customField: $contextField,
|
||||||
|
value: '1500',
|
||||||
|
composant: $composant,
|
||||||
|
machineComponentLink: $link,
|
||||||
|
);
|
||||||
|
|
||||||
|
$client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||||
|
'json' => [
|
||||||
|
'name' => 'Clone Machine',
|
||||||
|
'siteId' => $site->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
|
// Source must still expose its original context field value
|
||||||
|
$sourceData = $client->request('GET', '/api/machines/'.$source->getId().'/structure')->toArray();
|
||||||
|
$sourceLink = $sourceData['componentLinks'][0] ?? null;
|
||||||
|
$this->assertNotNull($sourceLink, 'Source machine should still expose its component link');
|
||||||
|
$this->assertCount(1, $sourceLink['contextCustomFieldValues']);
|
||||||
|
$this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class SessionProfileTest extends AbstractApiTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(401);
|
$this->assertResponseStatusCodeSame(401);
|
||||||
$this->assertJsonContains(['message' => 'Mot de passe incorrect.']);
|
$this->assertJsonContains(['message' => 'Identifiants invalides.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoginMissingPassword(): void
|
public function testLoginMissingPassword(): void
|
||||||
@@ -103,7 +103,7 @@ class SessionProfileTest extends AbstractApiTestCase
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(403);
|
$this->assertResponseStatusCodeSame(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetActiveProfileAuthenticated(): void
|
public function testGetActiveProfileAuthenticated(): void
|
||||||
|
|||||||
@@ -145,6 +145,69 @@ class ReferenceAutoGeneratorTest extends AbstractApiTestCase
|
|||||||
self::assertSame('U507', $result);
|
self::assertSame('U507', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testGenerateWithAccentedFieldName(): void
|
||||||
|
{
|
||||||
|
$mt = $this->createModelType('Palier', 'PAL-ACCENT', ModelCategory::PIECE);
|
||||||
|
$mt->setReferenceFormula('PA-{Diamètre}-33');
|
||||||
|
$mt->setRequiredFieldsForReference(['Diamètre']);
|
||||||
|
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$cf = $this->createCustomField('Diamètre', 'number', typePiece: $mt);
|
||||||
|
$piece = $this->createPiece('Palier 70', null, $mt);
|
||||||
|
$this->createCustomFieldValue($cf, '70', piece: $piece);
|
||||||
|
|
||||||
|
$em->refresh($piece);
|
||||||
|
|
||||||
|
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||||
|
$result = $generator->generate($piece);
|
||||||
|
|
||||||
|
self::assertSame('PA-70-33', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateWithNumberTypeField(): void
|
||||||
|
{
|
||||||
|
$mt = $this->createModelType('NumberField', 'NUM-001', ModelCategory::PIECE);
|
||||||
|
$mt->setReferenceFormula('R-{taille}');
|
||||||
|
$mt->setRequiredFieldsForReference(['taille']);
|
||||||
|
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$cf = $this->createCustomField('taille', 'number', typePiece: $mt);
|
||||||
|
$piece = $this->createPiece('Piece Number', null, $mt);
|
||||||
|
$this->createCustomFieldValue($cf, '42', piece: $piece);
|
||||||
|
|
||||||
|
$em->refresh($piece);
|
||||||
|
|
||||||
|
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||||
|
$result = $generator->generate($piece);
|
||||||
|
|
||||||
|
self::assertSame('R-42', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGenerateWithDecimalNumberField(): void
|
||||||
|
{
|
||||||
|
$mt = $this->createModelType('NumberDec', 'NUM-002', ModelCategory::PIECE);
|
||||||
|
$mt->setReferenceFormula('R-{taille}');
|
||||||
|
$mt->setRequiredFieldsForReference(['taille']);
|
||||||
|
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$cf = $this->createCustomField('taille', 'number', typePiece: $mt);
|
||||||
|
$piece = $this->createPiece('Piece Dec', null, $mt);
|
||||||
|
$this->createCustomFieldValue($cf, '12.5', piece: $piece);
|
||||||
|
|
||||||
|
$em->refresh($piece);
|
||||||
|
|
||||||
|
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
|
||||||
|
$result = $generator->generate($piece);
|
||||||
|
|
||||||
|
self::assertSame('R-12.5', $result);
|
||||||
|
}
|
||||||
|
|
||||||
public function testGenerateWithSpaceInFormula(): void
|
public function testGenerateWithSpaceInFormula(): void
|
||||||
{
|
{
|
||||||
$mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);
|
$mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);
|
||||||
|
|||||||
76
tests/Service/SkeletonStructureServiceTest.php
Normal file
76
tests/Service/SkeletonStructureServiceTest.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Enum\ModelCategory;
|
||||||
|
use App\Service\SkeletonStructureService;
|
||||||
|
use App\Tests\AbstractApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class SkeletonStructureServiceTest extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
public function testRenameCustomFieldPropagatesToReferenceFormulaAndRequiredFields(): void
|
||||||
|
{
|
||||||
|
$mt = $this->createModelType('Roulement', 'ROUL-RENAME', ModelCategory::PIECE);
|
||||||
|
$mt->setReferenceFormula('{material}-{size}');
|
||||||
|
$mt->setRequiredFieldsForReference(['material', 'size']);
|
||||||
|
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$cfMaterial = $this->createCustomField('material', 'text', typePiece: $mt, orderIndex: 0);
|
||||||
|
$cfSize = $this->createCustomField('size', 'text', typePiece: $mt, orderIndex: 1);
|
||||||
|
|
||||||
|
/** @var SkeletonStructureService $service */
|
||||||
|
$service = static::getContainer()->get(SkeletonStructureService::class);
|
||||||
|
|
||||||
|
// Same fields, but `material` is renamed to `materiau` (matched by customFieldId)
|
||||||
|
$service->updateSkeletonRequirements($mt, [
|
||||||
|
'customFields' => [
|
||||||
|
['customFieldId' => $cfMaterial->getId(), 'name' => 'materiau', 'type' => 'text', 'orderIndex' => 0],
|
||||||
|
['customFieldId' => $cfSize->getId(), 'name' => 'size', 'type' => 'text', 'orderIndex' => 1],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$em->flush();
|
||||||
|
$em->refresh($mt);
|
||||||
|
$em->refresh($cfMaterial);
|
||||||
|
|
||||||
|
self::assertSame('materiau', $cfMaterial->getName());
|
||||||
|
self::assertSame('{materiau}-{size}', $mt->getReferenceFormula());
|
||||||
|
self::assertSame(['materiau', 'size'], $mt->getRequiredFieldsForReference());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenameLeavesFormulaUnchangedWhenFieldNotInFormula(): void
|
||||||
|
{
|
||||||
|
$mt = $this->createModelType('Roulement2', 'ROUL-RENAME2', ModelCategory::PIECE);
|
||||||
|
$mt->setReferenceFormula('{material}');
|
||||||
|
$mt->setRequiredFieldsForReference(['material']);
|
||||||
|
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$cfMaterial = $this->createCustomField('material', 'text', typePiece: $mt, orderIndex: 0);
|
||||||
|
$cfUnused = $this->createCustomField('unused', 'text', typePiece: $mt, orderIndex: 1);
|
||||||
|
|
||||||
|
/** @var SkeletonStructureService $service */
|
||||||
|
$service = static::getContainer()->get(SkeletonStructureService::class);
|
||||||
|
|
||||||
|
$service->updateSkeletonRequirements($mt, [
|
||||||
|
'customFields' => [
|
||||||
|
['customFieldId' => $cfMaterial->getId(), 'name' => 'material', 'type' => 'text', 'orderIndex' => 0],
|
||||||
|
['customFieldId' => $cfUnused->getId(), 'name' => 'renamed', 'type' => 'text', 'orderIndex' => 1],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$em->flush();
|
||||||
|
$em->refresh($mt);
|
||||||
|
|
||||||
|
self::assertSame('{material}', $mt->getReferenceFormula());
|
||||||
|
self::assertSame(['material'], $mt->getRequiredFieldsForReference());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user