Compare commits

...

30 Commits

Author SHA1 Message Date
Matthieu
27d51ffdb1 fix(toasts) : auto-dismiss des notifications d'erreur apres 8 secondes
Les toasts d'erreur etaient persistants (duration force a 0) et restaient
affiches jusqu'a fermeture manuelle, ce qui pouvait empiler des messages
obsoletes a l'ecran. Aligne le comportement sur les autres types : duree
par defaut 8s (plus que warning a 6s pour laisser le temps de lire). Une
erreur critique peut toujours etre rendue persistante en passant
explicitement showError(msg, 0).
2026-05-06 16:51:08 +02:00
Matthieu
53d4d5768b refactor(doc) : utilise palier comme exemple plus parlant que pompe
Remplace l'exemple "pompe avec position sur la machine" par un palier
de tete vs palier de pied : exemple plus concret et plus universellement
compris pour illustrer la difference entre champs catalogue et champs
contextuels (custom field values).
2026-05-06 16:40:37 +02:00
Matthieu
3ff89d43ed fix(db) : ajoute les FK CASCADE manquantes documents.composantId et machine_component_links.composantId
Les entités Doctrine déclaraient déjà onDelete: CASCADE pour ces deux
relations, mais les contraintes correspondantes étaient absentes en base.
Conséquence : la suppression d'un composant pouvait laisser des documents
ou des links machine orphelins. La migration nettoie les orphelins
existants (avec trace dans audit_logs) puis ajoute les deux FK.
2026-05-06 16:34:26 +02:00
Matthieu
5c55441e6c fix(audit) : visibilité protected pour ActorProfileResolver
AbstractAuditSubscriber déclarait $actorProfileResolver en private readonly
via promoted property. MachineAuditSubscriber surcharge onFlush() et accède
à $this->actorProfileResolver, mais private n'est pas hérité — PHP voyait
null et levait "Call to a member function resolve() on null" sur chaque
flush Doctrine touchant des link entities.

Le passage à protected suit la convention déjà en place dans la classe
(safeGet, normalizeValue, persistAuditLog, etc. sont protected). readonly
préserve l'immutabilité de la dépendance DI.

Ajoute aussi deux tests de régression pour le clone des contextFieldValues
(symétrique au test composant existant) et nettoie deux lignes vides
cosmétiques laissées par le refactor précédent.

- testCloneMachineCopiesPieceContextFieldValues : vérifie que les CFV
  context d'un MachinePieceLink sont bien rattachées au nouveau lien
  après clone.
- testCloneMachineLeavesSourceContextFieldValuesIntact : vérifie que la
  machine source garde ses CFV context après clone (invariant implicite).
2026-05-06 15:30:59 +02:00
Matthieu
e432153083 refactor : simplification globale (vague 1 + 2)
- ActorProfileResolver : service unique partage par AbstractAuditSubscriber, EntityVersionService et ModelTypeCategoryConversionService (3 implementations dupliquees+divergentes)
- corrige un bug latent : EntityVersionService restoraitsans le fallback Security::getUser, loggant actor=null hors session
- machine-clone : clonage des contextFieldValues integre dans cloneComponentLinks/clonePieceLinks, supprime cloneContextFieldValues et son find() en boucle
- helpers extraits : serializeProductSlots (EntityVersionService), updateModelTypeCategory (ModelTypeCategoryConversionService)
- supprime collectCollectionUpdate() vide + ses appels (AbstractAuditSubscriber)
- useMachineDetailData : retire debug ref couplee a isEditMode, componentTypeLabelMap/pieceTypeLabelMap jamais consommes, double assignation machine.productLinks
- PieceItem : retire l'init pieceData dans onMounted (deja couvert par reactive() et le watcher)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 10:14:23 +02:00
Matthieu
b16b619fc9 docs : ajoute note delegation Codex pour taches mecaniques 2026-05-06 09:52:08 +02:00
gitea-actions
c88333b052 chore : bump version to v1.9.29
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m6s
2026-05-03 18:05:16 +00:00
8f5cd98b82 fix(machine-clone) : preserve context field values when cloning a machine
All checks were successful
Auto Tag Develop / tag (push) Successful in 35s
Context CustomFieldValues attached to component/piece links were
silently dropped from the clone response (and from any subsequent
read in the same request) because the controller persisted the new
CFVs without adding them to the inverse-side collection of the new
link. Doctrine does not auto-sync inverse OneToMany associations,
so getContextFieldValues() returned an empty collection on the
freshly persisted link.

Also synchronise the inverse collection in the test factory so
identity-mapped entities reflect newly-created CFVs when reused
by request handlers within the same test.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:59:03 +02:00
48f7e4c6ac test(session) : align expectations with hardened auth from WIP 476060c
Generic 'Identifiants invalides.' is now returned for both wrong
password and missing-password-set cases (security obscurity, prevents
account enumeration). Tests still asserted the granular 'Mot de passe
incorrect.' message and a 403 status that the controller no longer
emits.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:56:53 +02:00
c46769a67d fix(model-types) : nullify weak references on ModelType delete
Belt-and-suspenders against orphan refs when a ModelType is deleted:
applicatively nullifies typeComposantId / typePieceId / typeProductId
on every "ON DELETE SET NULL" relationship before the row is removed,
in case the database FK cascade fails to fire.

Observed in prod 2026-04-28: deletion of ModelType "Paliers" left an
orphan in skeleton_subcomponent_requirements, surfacing as a 500 when
API Platform tried to lazy-load the missing proxy.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:29:36 +02:00
gitea-actions
28394ce1b4 chore : bump version to v1.9.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 3m16s
2026-04-10 14:57:59 +00:00
Matthieu
8cfcb41a39 feat(conversion) : commande CLI pour convertir la catégorie Moteur de PIECE vers COMPONENT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Migre les 18 pièces en composants, transfère documents, custom fields,
slots et skeleton requirements dans une transaction. Supporte --dry-run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:57:46 +02:00
gitea-actions
980a7c310e chore : bump version to v1.9.27
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 2m32s
2026-04-09 12:34:46 +00:00
Matthieu
00f18d1c7d feat(infra) : add monolog logging and persist logs in prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Install symfony/monolog-bundle with rotating_file handlers.
Add named volume inventory_logs for prod log persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:33:42 +02:00
gitea-actions
6e2c5179a9 chore : bump version to v1.9.26
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 34s
2026-04-06 18:46:40 +00:00
3cd18a721a feat(ui) : refonte cartes dépliantes structure machine + DataTable parc machines + fix activity-log
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Parc Machines transformé en DataTable avec filtres (site, date création, recherche)
- Vue d'ensemble : ajout filtre par plage de dates de création
- Activity-log : correction des liens entités (routes singulier sans /edit, ajout machine/document/model_type)
- ComponentItem & PieceItem : refonte complète des cartes dépliantes (design industriel raffiné)
  - Header compact avec tags colorés contrastés (référence, réf. auto, prix, produit, champs machine)
  - Panneau déplié structuré en sections avec mini-headers
  - Bordure gauche primary pour hiérarchie visuelle
- Ajout referenceAuto dans header et infos pour composants et pièces
- Suppression double encadrement ComponentHierarchy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:46:25 +02:00
gitea-actions
191e071957 chore : bump version to v1.9.25
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-06 16:54:32 +00:00
f964df76b9 feat(custom-fields) : messages warning champs obligatoires + commandes make frontend
All checks were successful
Auto Tag Develop / tag (push) Successful in 10s
Ajoute des messages visuels (warning + error) quand des champs perso
obligatoires ne sont pas renseignés sur les pages composant (création
et édition). Ajoute make test-front et make test-front-watch au Makefile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:54:22 +02:00
gitea-actions
6744542f84 chore : bump version to v1.9.24
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 37s
2026-04-06 15:23:07 +00:00
3e0e9d5270 feat(categories) : aligner design catégories sur catalogues
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Ajoute colonne createdAt triable dans la datatable des catégories
- Retire le bouton « Créer » de la vue catégorie (ManagementView)
- Retire l'action « Convertir » de toutes les catégories
- Le bouton « Ajouter » des pages catalogue switch selon l'onglet
  actif : crée un item (catalogue) ou une catégorie (catégories)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:22:57 +02:00
gitea-actions
4e0efc11ba chore : bump version to v1.9.23
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 38s
2026-04-06 15:18:20 +00:00
9fc88df3ff fix(piece) : rendre les slots produit optionnels en création et édition
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Les sélections de produits liés ne bloquent plus la soumission du
formulaire de création ou d'édition de pièce. Les slots vides restent
visibles et peuvent être remplis ultérieurement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:18:10 +02:00
gitea-actions
041a04f0e9 chore : bump version to v1.9.22
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 36s
2026-04-06 15:15:37 +00:00
d089cd4873 fix(model-type) : masquer uniquement les produits, garder les champs perso
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Ajoute une prop hideProducts au PieceModelStructureEditor pour masquer
la section « Produits inclus par défaut » sans retirer les champs
personnalisés. Utilisé pour les catégories PRODUCT.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:15:26 +02:00
gitea-actions
b304cf6684 chore : bump version to v1.9.21
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 36s
2026-04-06 15:12:40 +00:00
0fe7f3131e fix(model-type) : retirer l'éditeur de structure produit inutilisé
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Le PieceModelStructureEditor affiché pour les catégories PRODUCT ne
fonctionnait plus et n'est plus utilisé.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:12:29 +02:00
a6bbcaf6d1 fix(custom-fields) : masquer les champs machineContextOnly hors vue machine
Ajoute context: 'standalone' aux appels useCustomFieldInputs dans les
vues composant, pièce et produit (création et édition) pour filtrer
les champs perso réservés au contexte machine.

Exclut également ces champs de la formule de référence automatique
dans le ReferenceFormulaBuilder des catégories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:12:29 +02:00
9f2e1da6ec fix(composant) : rendre les slots de structure optionnels à la création
Les emplacements pièces, produits et sous-composants du squelette ne
bloquent plus la soumission du formulaire de création de composant.
Les slots vides restent visibles en consultation avec l'indicateur rouge
« manquant » et peuvent être remplis ultérieurement en édition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:12:29 +02:00
gitea-actions
7962576eec chore : bump version to v1.9.20
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 37s
2026-04-06 14:54:20 +00:00
7d98c1598c fix(deps) : update composer.lock with symfony/mime
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:54:09 +02:00
47 changed files with 2141 additions and 955 deletions

View File

@@ -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"
}
} }
} }
} }

View File

@@ -264,3 +264,12 @@ make test-setup # Créer/mettre à jour le schéma test
- Nuxt dev : `http://localhost:3001` - Nuxt dev : `http://localhost:3001`
- Adminer (PG) : `http://localhost:5050` - Adminer (PG) : `http://localhost:5050`
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory) - PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
## Delegation Codex
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
C'est le meilleur ratio qualite/credits.

View File

@@ -24,6 +24,7 @@
"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/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
View File

@@ -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",

View File

@@ -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],
]; ];

View 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

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.19' app.version: '1.9.29'

View File

@@ -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"

View File

@@ -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,282 @@
> >
À remplir À remplir
</button> </button>
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span> <span v-if="component.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ component.reference }}</span>
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span> <span v-if="component.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ component.referenceAuto }}</span>
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}</span>
</div> </div>
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
<!-- Row 2: Metadata tags -->
<div
v-if="componentConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
class="flex flex-wrap items-center gap-1.5"
>
<span <span
v-for="constructeur in componentConstructeursDisplay" v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id" :key="constructeur.id"
class="text-xs text-base-content/50" class="text-[0.65rem] text-base-content/45"
> >
{{ constructeur.name }} {{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span> <span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span> </span>
<span v-if="displayProductName" class="badge badge-info badge-xs"> <span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }} {{ displayProductName }}
</span> </span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
>
{{ field.name }} : {{ field.value }}
</span>
</template>
</div> </div>
</div> </div>
<!-- Delete button -->
<button <button
v-if="showDelete" v-if="showDelete"
type="button" type="button"
class="btn btn-ghost btn-xs text-error shrink-0" class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
title="Supprimer ce composant" title="Supprimer ce composant"
@click.stop="$emit('delete')" @click.stop="$emit('delete')"
> >
Supprimer <IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</div> </div>
<!-- Expanded content --> <!-- EXPANDED PANEL -->
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7"> <div v-show="!isCollapsed && !component.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
<!-- Info fields -->
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3"> <!-- Section: Informations -->
<div class="form-control"> <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent"> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
</div> </div>
<div class="form-control"> <div class="p-4">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label> <!-- Edit mode -->
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent"> <div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Nom</span></label>
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
<ConstructeurSelect
class="w-full"
:model-value="componentConstructeurIds"
:initial-options="componentConstructeursDisplay"
@update:model-value="handleConstructeurChange"
/>
</div>
</div>
<!-- Read-only mode -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Nom</p>
<p class="text-sm text-base-content font-medium">{{ component.name }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
<p class="text-sm text-base-content" :class="component.reference ? 'font-mono' : 'text-base-content/30'">{{ component.reference || '—' }}</p>
</div>
<div v-if="component.referenceAuto">
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
<p class="text-sm text-base-content font-mono">{{ component.referenceAuto }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
<p class="text-sm" :class="component.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ component.prix ? `${component.prix}` : '—' }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
<p
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-sm text-base-content"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
<span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
{{ formatConstructeurContact(constructeur) }}
</span>
</p>
</div>
<p v-else class="text-sm text-base-content/30"></p>
</div>
</div>
</div> </div>
<div class="form-control"> </div>
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent"> <!-- Section: Produit catalogue -->
<div v-if="displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-info/10 border-b border-info/20">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
</div> </div>
<div class="form-control"> <div class="p-4">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label> <div class="flex items-start justify-between gap-3">
<ConstructeurSelect <div class="space-y-1.5">
class="w-full" <p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
:model-value="componentConstructeurIds" <div class="flex flex-wrap gap-x-4 gap-y-1">
:initial-options="componentConstructeursDisplay" <p
@update:model-value="handleConstructeurChange" v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/55"
>
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
</div>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}`"
class="btn btn-ghost btn-xs shrink-0 gap-1"
>
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
Voir
</NuxtLink>
</div>
<!-- Product documents -->
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200/50 space-y-2">
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/35">Documents du produit</p>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-4 w-4"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<span class="truncate text-base-content">{{ document.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Section: Champs personnalisés -->
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
</div>
<div class="p-4">
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="false"
@field-blur="updateComponentCustomField"
/> />
</div> </div>
</div> </div>
<!-- Read-only info --> <div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm"> <div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
<div> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
<p class="text-base-content">{{ component.name }}</p>
</div> </div>
<div> <div class="p-4">
<p class="text-xs text-base-content/40 mb-0.5">Référence</p> <CustomFieldDisplay
<p class="text-base-content">{{ component.reference || '—' }}</p> :fields="mergedContextFields"
</div> :is-edit-mode="isEditMode"
<div> :columns="2"
<p class="text-xs text-base-content/40 mb-0.5">Prix</p> :show-header="false"
<p class="text-base-content">{{ component.prix ? `${component.prix}` : '—' }}</p> :with-top-border="false"
</div> :editable="true"
<div> :emit-blur="false"
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p> @field-input="queueContextCustomFieldUpdate"
<div v-if="componentConstructeursDisplay.length"> />
<p
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-base-content"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
{{ formatConstructeurContact(constructeur) }}
</span>
</p>
</div>
<p v-else class="text-base-content"></p>
</div> </div>
</div> </div>
<!-- Product --> <!-- Section: Documents -->
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3"> <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="flex items-start justify-between gap-3"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
<div class="space-y-1"> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
<p class="text-xs text-base-content/40">Produit catalogue</p>
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/60"
>
{{ info.label }} : {{ info.value }}
</p>
</div>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}`"
class="btn btn-ghost btn-xs shrink-0"
>
Voir le produit
</NuxtLink>
</div>
<!-- Product documents -->
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-4 w-4"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<span class="truncate text-base-content">{{ document.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
title="Champs personnalisés item"
:editable="false"
@field-blur="updateComponentCustomField"
/>
<template v-if="mergedContextFields.length">
<div class="divider my-4 text-xs text-base-content/50">
Champs personnalisés machine
</div>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<!-- Documents -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs"> <span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }} {{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span> </span>
</div> </div>
<div class="p-4 space-y-3">
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement...</p>
<p v-if="loadingDocuments" class="text-xs text-base-content/50"> <DocumentUpload
Chargement... v-if="isEditMode"
</p> v-model="selectedFiles"
title="Déposer des fichiers pour ce composant"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded"
/>
<DocumentUpload <DocumentListInline
v-if="isEditMode" :documents="componentDocuments"
v-model="selectedFiles" :can-delete="isEditMode"
title="Déposer des fichiers pour ce composant" :can-edit="isEditMode"
subtitle="Formats acceptés : PDF, images, documents..." :delete-disabled="uploadingDocuments"
@files-added="handleFilesAdded" empty-text="Aucun document lié à ce composant."
/> @preview="openPreview"
@edit="openEditModal"
<DocumentListInline @delete="removeDocument"
:documents="componentDocuments" />
:can-delete="isEditMode" </div>
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à ce composant."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div> </div>
<!-- Component Pieces (real MachinePieceLinks) --> <!-- Section: Pièces du composant -->
<div v-if="linkedPieces.length > 0" class="space-y-2"> <div v-if="linkedPieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
Pièces du composant <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
</p> Pièces du composant
<div class="space-y-2"> <span class="ml-1 text-base-content/25">({{ linkedPieces.length }})</span>
</p>
</div>
<div class="p-3 space-y-2">
<PieceItem <PieceItem
v-for="piece in linkedPieces" v-for="piece in linkedPieces"
:key="piece.id" :key="piece.id"
@@ -290,12 +353,15 @@
</div> </div>
</div> </div>
<!-- Structure pieces (read-only, from composant definition) --> <!-- ── Section: Pièces structure ── -->
<div v-if="structurePieces.length > 0" class="space-y-2"> <div v-if="structurePieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
Pièces incluses par défaut <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
</p> Pièces incluses par défaut
<div class="space-y-2"> <span class="ml-1 text-base-content/25">({{ structurePieces.length }})</span>
</p>
</div>
<div class="p-3 space-y-2">
<PieceItem <PieceItem
v-for="piece in structurePieces" v-for="piece in structurePieces"
:key="piece.id" :key="piece.id"
@@ -305,12 +371,15 @@
</div> </div>
</div> </div>
<!-- Sub Components --> <!-- ── Section: Sous-composants ── -->
<div v-if="childComponents.length > 0" class="space-y-2"> <div v-if="childComponents.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
Sous-composants <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
</p> Sous-composants
<div class="space-y-2 pl-4 border-l-2 border-base-200"> <span class="ml-1 text-base-content/25">({{ childComponents.length }})</span>
</p>
</div>
<div class="p-3 space-y-2">
<ComponentItem <ComponentItem
v-for="subComponent in childComponents" v-for="subComponent in childComponents"
:key="subComponent.id" :key="subComponent.id"
@@ -336,6 +405,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 +532,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

View File

@@ -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,333 @@
@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 2: Metadata tags -->
</template> <div
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span> v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
class="flex flex-wrap items-center gap-1.5"
>
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
{{ piece.parentComponentName }}
</span>
<span
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="text-[0.65rem] text-base-content/45"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span>
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }}
</span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span <span
v-if="displayProductName" v-for="field in visibleContextFieldTags"
class="badge badge-info badge-sm" :key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
> >
Produit&nbsp;: {{ displayProductName }} {{ field.name }} : {{ field.value }}
</span> </span>
</div> </template>
</div> </div>
</div> </div>
<!-- Delete button -->
<button <button
v-if="showDelete" v-if="showDelete"
type="button" type="button"
class="btn btn-ghost btn-xs text-error shrink-0" class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
title="Supprimer cette pièce" title="Supprimer cette pièce"
@click="$emit('delete')" @click.stop="$emit('delete')"
> >
Supprimer <IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</div> </div>
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4"> <!-- EXPANDED PANEL -->
<div class="p-4 bg-base-100 border border-base-200 rounded-lg"> <div v-show="!isCollapsed && !piece.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
<div class="space-y-2 text-sm">
<div v-if="isEditMode" class="form-control"> <!-- Section: Informations -->
<label class="label"> <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<span class="label-text text-sm">Quantité</span> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
</label> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
<input </div>
v-model.number="pieceData.quantity" <div class="p-4">
type="number" <!-- Edit mode -->
min="1" <div v-if="isEditMode" class="space-y-3">
step="1" <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
class="input input-bordered input-sm md:input-md w-24" <div class="form-control">
@blur="updatePiece" <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Quantité</span></label>
/> <input
</div> v-model.number="pieceData.quantity"
<div v-else-if="displayQuantity > 1"> type="number"
<span class="font-medium">Quantité:</span> min="1"
<span class="ml-2">{{ displayQuantity }}</span> step="1"
</div> class="input input-bordered input-sm w-full"
<div> @blur="updatePiece"
<span class="font-medium">Référence:</span> />
<input </div>
v-if="isEditMode" <div class="form-control">
:id="`piece-reference-${piece.id}`" <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
v-model="pieceData.reference" <input
type="text" :id="`piece-reference-${piece.id}`"
class="input input-sm input-bordered ml-2" v-model="pieceData.reference"
@blur="updatePiece" type="text"
/> class="input input-bordered input-sm w-full"
<span v-else class="ml-2">{{ @blur="updatePiece"
pieceData.reference || "Non définie" />
}}</span> </div>
</div> <div class="form-control">
<div v-if="pieceData.referenceAuto"> <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
<span class="font-medium">Référence auto:</span> <input
<span class="ml-2">{{ pieceData.referenceAuto }}</span> :id="`piece-prix-${piece.id}`"
</div> v-model="pieceData.prix"
<div> type="number"
<span class="font-medium">Fournisseur:</span> step="0.01"
<div v-if="!isEditMode" class="ml-2"> class="input input-bordered input-sm w-full"
<div v-if="pieceConstructeursDisplay.length" class="space-y-1"> @blur="updatePiece"
<div />
v-for="constructeur in pieceConstructeursDisplay" </div>
:key="constructeur.id" </div>
class="flex flex-col" <div class="form-control">
> <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
<span class="font-medium"> <ConstructeurSelect
{{ constructeur.name }} class="w-full"
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60"> :model-value="pieceConstructeurIds"
Réf. {{ supplierReferenceMap.get(constructeur.id) }} :initial-options="pieceConstructeursDisplay"
</span> placeholder="Sélectionner un ou plusieurs fournisseurs..."
</span> @update:model-value="handleConstructeurChange"
<span />
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-base-content/50"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div> </div>
</div> </div>
<span v-else class="font-medium"> <!-- Read-only mode -->
Non défini <div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
</span> <div v-if="displayQuantity > 1">
</div> <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Quantité</p>
<ConstructeurSelect <p class="text-sm text-base-content font-medium">{{ displayQuantity }}</p>
v-else </div>
class="w-full" <div>
:model-value="pieceConstructeurIds" <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
:initial-options="pieceConstructeursDisplay" <p class="text-sm" :class="pieceData.reference ? 'text-base-content font-mono' : 'text-base-content/30'">{{ pieceData.reference || '—' }}</p>
placeholder="Sélectionner un ou plusieurs fournisseurs..." </div>
@update:model-value="handleConstructeurChange" <div v-if="pieceData.referenceAuto">
/> <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
</div> <p class="text-sm text-base-content font-mono">{{ pieceData.referenceAuto }}</p>
<div> </div>
<span class="font-medium">Prix:</span> <div>
<input <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
v-if="isEditMode" <p class="text-sm" :class="pieceData.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ pieceData.prix ? `${pieceData.prix}` : '—' }}</p>
:id="`piece-prix-${piece.id}`" </div>
v-model="pieceData.prix" <div>
type="number" <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
step="0.01" <div v-if="pieceConstructeursDisplay.length" class="space-y-1">
class="input input-sm input-bordered ml-2" <p
@blur="updatePiece" v-for="constructeur in pieceConstructeursDisplay"
/> :key="constructeur.id"
<span v-else class="ml-2">{{ class="text-sm text-base-content"
pieceData.prix ? `${pieceData.prix}` : "Non défini" >
}}</span> {{ constructeur.name }}
</div> <span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
<div> Réf. {{ supplierReferenceMap.get(constructeur.id) }}
<span class="font-medium">Produit catalogue:</span> </span>
<div v-if="isEditMode" class="mt-2 space-y-2"> <span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
<ProductSelect {{ formatConstructeurContact(constructeur) }}
:model-value="pieceData.productId" </span>
placeholder="Associer un produit…" </p>
helper-text="Optionnel : reliez cette pièce à un produit catalogue." </div>
@update:modelValue="handleProductChange" <p v-else class="text-sm text-base-content/30"></p>
/> </div>
<div
v-if="selectedProduct"
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
>
<p class="text-sm font-semibold text-base-content">
{{ selectedProduct.name }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="flex flex-wrap gap-1"
>
<span class="font-semibold">{{ info.label }} :</span>
<span>{{ info.value }}</span>
</p>
<NuxtLink
v-if="selectedProduct.id"
:to="`/product/${selectedProduct.id}`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</div> </div>
<p v-else class="text-xs text-base-content/60">
Aucun produit associé.
</p>
</div> </div>
<div class="ml-2"> </div>
<div v-if="displayProduct" class="space-y-1">
<p class="font-medium text-base-content"> <!-- Section: Produit catalogue -->
{{ displayProductName || 'Produit catalogue' }} <div v-if="isEditMode || displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
</p> <div class="px-4 py-2 bg-info/10 border-b border-info/20">
<p <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
v-for="info in productInfoRows" </div>
:key="info.label" <div class="p-4">
class="text-xs text-base-content/70" <!-- Edit mode -->
<div v-if="isEditMode" class="space-y-3">
<ProductSelect
:model-value="pieceData.productId"
placeholder="Associer un produit…"
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
@update:modelValue="handleProductChange"
/>
<div
v-if="selectedProduct"
class="rounded-lg border border-base-200/60 bg-base-200/20 p-3 space-y-1.5"
> >
<span class="font-semibold">{{ info.label }} :</span> <p class="text-sm font-bold text-base-content">{{ selectedProduct.name }}</p>
<span class="ml-1">{{ info.value }}</span> <div class="flex flex-wrap gap-x-4 gap-y-1">
</p> <p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
<NuxtLink v-if="selectedProduct.id" :to="`/product/${selectedProduct.id}`" class="link link-primary text-xs">
Ouvrir la fiche produit
</NuxtLink>
</div>
</div>
<!-- Read-only mode -->
<div v-else-if="displayProduct">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1.5">
<p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
</div>
<NuxtLink
v-if="piece.product?.id || piece.productId"
:to="`/product/${piece.product?.id || piece.productId}`"
class="btn btn-ghost btn-xs shrink-0 gap-1"
>
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
Voir
</NuxtLink>
</div>
<ProductDocumentsInline <ProductDocumentsInline
v-if="productDocuments.length"
class="mt-3 pt-3 border-t border-base-200/50"
:documents="productDocuments" :documents="productDocuments"
@preview="openPreview" @preview="openPreview"
/> />
</div> </div>
<span v-else class="font-medium">
Non défini
</span>
</div> </div>
</div> </div>
</div>
<!-- Section: Champs personnalisés item -->
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
</div>
<div class="p-4">
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:show-header="false"
:with-top-border="false"
:editable="false"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
</div>
</div> </div>
<!-- Champs personnalisés de la pièce --> <!-- Section: Champs personnalisés machine -->
<CustomFieldDisplay <div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
:fields="displayedCustomFields" <div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
:is-edit-mode="isEditMode" <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
title="Champs personnalisés item" </div>
:editable="false" <div class="p-4">
@field-input="handleCustomFieldInput" <CustomFieldDisplay
@field-blur="handleCustomFieldBlur" :fields="mergedContextFields"
/> :is-edit-mode="isEditMode"
:columns="2"
<template v-if="mergedContextFields.length"> :show-header="false"
<div class="divider my-4 text-xs text-base-content/50"> :with-top-border="false"
Champs personnalisés machine :editable="true"
</div> :emit-blur="false"
<CustomFieldDisplay @field-input="queueContextCustomFieldUpdate"
:fields="mergedContextFields" />
:is-edit-mode="isEditMode" </div>
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
<span
v-if="isEditMode && selectedFiles.length"
class="badge badge-outline"
>
{{ selectedFiles.length }} fichier{{
selectedFiles.length > 1 ? "s" : ""
}}
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
</span>
</div> </div>
<p v-if="loadingDocuments" class="text-xs text-base-content/50"> <!-- Section: Documents -->
Chargement des documents... <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
</p> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</div>
<div class="p-4 space-y-3">
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement des documents...</p>
<DocumentUpload <DocumentUpload
v-if="isEditMode" v-if="isEditMode"
v-model="selectedFiles" v-model="selectedFiles"
title="Déposer des fichiers pour cette pièce" title="Déposer des fichiers pour cette pièce"
subtitle="Formats acceptés : PDF, images, documents..." subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded" @files-added="handleFilesAdded"
/> />
<DocumentListInline <DocumentListInline
:documents="pieceDocuments" :documents="pieceDocuments"
:can-delete="isEditMode" :can-delete="isEditMode"
:can-edit="isEditMode" :can-edit="isEditMode"
:delete-disabled="uploadingDocuments" :delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à cette pièce." empty-text="Aucun document lié à cette pièce."
@preview="openPreview" @preview="openPreview"
@edit="openEditModal" @edit="openEditModal"
@delete="removeDocument" @delete="removeDocument"
/> />
</div> </div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -321,6 +351,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 +500,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 +739,7 @@ watch(
) )
onMounted(() => { onMounted(() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {}) loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments() if (!props.piece.documents?.length) refreshDocuments()
}) })
</script> </script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<section class="space-y-3"> <section v-if="!hideProducts" class="space-y-3">
<header> <header>
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
Produits inclus par défaut Produits inclus par défaut
@@ -166,6 +166,7 @@ defineOptions({ name: 'PieceModelStructureEditor' })
const props = defineProps<{ const props = defineProps<{
modelValue?: PieceModelStructure | null modelValue?: PieceModelStructure | null
hideProducts?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -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) => {

View File

@@ -99,11 +99,7 @@
v-else v-else
class="space-y-3 rounded-lg border border-base-300 p-4" class="space-y-3 rounded-lg border border-base-300 p-4"
> >
<p class="text-sm text-base-content/70"> <PieceModelStructureEditor v-model="productStructure" hide-products />
Aperçu :
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
</p>
<PieceModelStructureEditor v-model="productStructure" />
</div> </div>
</template> </template>
</section> </section>
@@ -194,15 +190,16 @@ const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
}) })
const formulaBuilderCustomFields = computed(() => { const formulaBuilderCustomFields = computed(() => {
let fields: any[] = []
if (form.category === 'PIECE') { if (form.category === 'PIECE') {
const fields = pieceStructure.value?.customFields const raw = pieceStructure.value?.customFields
return Array.isArray(fields) ? fields : [] fields = Array.isArray(raw) ? raw : []
} }
if (form.category === 'COMPONENT') { else if (form.category === 'COMPONENT') {
const fields = componentStructure.value?.customFields const raw = componentStructure.value?.customFields
return Array.isArray(fields) ? fields : [] fields = Array.isArray(raw) ? raw : []
} }
return [] return fields.filter((f: any) => !f.machineContextOnly)
}) })
const extractFormulaFields = (formula: string | null | undefined): string[] => { const extractFormulaFields = (formula: string | null | undefined): string[] => {

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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 => {

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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' },

View File

@@ -715,10 +715,12 @@
</p> </p>
<p class="text-base-content/70 leading-relaxed mb-4"> <p class="text-base-content/70 leading-relaxed mb-4">
<strong>Pourquoi ?</strong> Certaines informations n'ont de sens que quand <strong>Pourquoi ?</strong> Certaines informations n'ont de sens que quand
l'element est monte sur une machine. Par exemple, la "position sur la machine" l'element est monte sur une machine. Prenons l'exemple d'un palier : sur une
d'une pompe : dans le catalogue, la pompe n'est montee nulle part, donc ce champ machine, vous en avez souvent deux, un en haut (le palier de tete) et un en
ne sert a rien. Mais quand on regarde cette pompe depuis la fiche d'une machine, bas (le palier de pied). Dans le catalogue, le palier n'est monte nulle part,
on veut savoir ou elle est installee. donc savoir s'il est "en haut" ou "en bas" ne veut rien dire. Mais des qu'on
regarde ce palier depuis la fiche d'une machine, on veut savoir lequel des
deux c'est.
</p> </p>
</div> </div>
</div> </div>
@@ -731,16 +733,16 @@
<p class="text-xs text-base-content/40 mb-4">Quand on consulte l'element tout seul</p> <p class="text-xs text-base-content/40 mb-4">Quand on consulte l'element tout seul</p>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">Debit max</span> <span class="text-base-content/70">Diametre interieur</span>
<span>120 L/min</span> <span>50 mm</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">ATEX</span> <span class="text-base-content/70">Type</span>
<span>Oui</span> <span>Roulement a billes</span>
</div> </div>
<div class="border-t border-dashed border-base-300 pt-2 mt-2"> <div class="border-t border-dashed border-base-300 pt-2 mt-2">
<div class="flex justify-between opacity-30"> <div class="flex justify-between opacity-30">
<span class="line-through">Position sur la machine</span> <span class="line-through">Emplacement</span>
<span class="text-xs italic">pas affiche ici</span> <span class="text-xs italic">pas affiche ici</span>
</div> </div>
</div> </div>
@@ -753,17 +755,17 @@
<p class="text-xs text-base-content/40 mb-4">Quand on regarde l'element dans sa machine</p> <p class="text-xs text-base-content/40 mb-4">Quand on regarde l'element dans sa machine</p>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">Debit max</span> <span class="text-base-content/70">Diametre interieur</span>
<span>120 L/min</span> <span>50 mm</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">ATEX</span> <span class="text-base-content/70">Type</span>
<span>Oui</span> <span>Roulement a billes</span>
</div> </div>
<div class="border-t border-primary/20 pt-2 mt-2"> <div class="border-t border-primary/20 pt-2 mt-2">
<div class="flex justify-between bg-primary/10 rounded px-2 py-1.5"> <div class="flex justify-between bg-primary/10 rounded px-2 py-1.5">
<span class="text-base-content font-medium">Position sur la machine</span> <span class="text-base-content font-medium">Emplacement</span>
<span class="font-bold">Secteur B - Ligne 3</span> <span class="font-bold">Haut (palier de tete)</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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) => {

View File

@@ -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`)
} }

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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),

View File

@@ -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[],

View File

@@ -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"
}, },

View File

@@ -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:

View File

@@ -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

View 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');
}
}

View 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],
),
];
}
}

View File

@@ -162,9 +162,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 +227,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 +277,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 +336,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)) {

View File

@@ -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,

View File

@@ -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);
} }

View 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],
);
}
}
}

View 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;
}
}

View File

@@ -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;
} }
} }

View File

@@ -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;
}
} }

View File

@@ -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": {

View File

@@ -7,10 +7,10 @@ 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\CustomField; use App\Entity\CustomField;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
@@ -467,6 +467,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;
} }

View File

@@ -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']);
}
} }

View File

@@ -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