Compare commits

..

147 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
gitea-actions
4772f057a3 chore : bump version to v1.9.19
Some checks failed
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Failing after 9s
2026-04-06 14:52:56 +00:00
6680423e64 fix(deps) : add symfony/mime as explicit dependency
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Was previously pulled as transitive dependency but disappeared after
composer update, causing 500 errors on document upload in production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:42 +02:00
2c2de8bc00 test(machine-detail) : add hierarchy loading and override data integrity tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:42 +02:00
150aceac24 test(piece-edit,documents) : add productIds sync, error paths, and document CRUD tests 2026-04-06 16:52:42 +02:00
972f30e772 test(component-create) : add structure, error path, and null handling tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:42 +02:00
8af68c9628 test(component-edit) : add document, error path, and null handling tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:42 +02:00
eb68336723 test(machine-custom-fields) : add checkbox and data integrity tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:42 +02:00
eeba229574 test(piece-edit) : add edit flow and product slot data integrity tests 2026-04-06 16:52:41 +02:00
4454bbea3d test(component-edit) : add edit flow and slot data integrity tests 2026-04-06 16:52:41 +02:00
1e40334e11 test(component-create) : add creation flow data integrity tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:41 +02:00
83c75ecf69 test(crud) : add CRUD cache data integrity tests for products, composants, pieces
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:41 +02:00
b54739f6de test(custom-fields) : add data integrity tests for all field types 2026-04-06 16:52:41 +02:00
82cbeb91a5 test(constructeur-links) : add sync algorithm data integrity tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:41 +02:00
e70c66e215 test(fixtures) : add shared mock data for data integrity tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:52:41 +02:00
gitea-actions
1c07c96184 chore : bump version to v1.9.18
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 37s
2026-04-06 09:39:15 +00:00
122170c3fd feat(ui) : add documentation page
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 11:34:18 +02:00
gitea-actions
3f5e4b7f51 chore : bump version to v1.9.17
All checks were successful
Auto Tag Develop / tag (push) Successful in 16s
Build & Push Docker Image / build (push) Successful in 38s
2026-04-06 09:23:53 +00:00
0832af86cc Merge branch 'feat/ux-quick-wins' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
# Conflicts:
#	frontend/app/components/ComponentItem.vue
#	frontend/app/components/PieceItem.vue
#	frontend/app/components/machine/MachineInfoCard.vue
#	frontend/app/composables/usePieceEdit.ts
#	frontend/app/pages/component/[id]/index.vue
#	frontend/app/pages/piece/[id].vue
#	frontend/app/pages/product/[id]/edit.vue
#	frontend/app/pages/product/[id]/index.vue
#	frontend/app/pages/product/create.vue
#	frontend/app/shared/utils/customFields.ts
2026-04-06 11:23:21 +02:00
44b6e0998c fix(custom-fields) : fix declaration order in useComponentEdit and useComponentCreate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:20:08 +02:00
c4ed8c8edc refactor(custom-fields) : unify 3 parallel implementations into 1 module
Replace ~2900 lines across 9 files with ~400 lines in 2 files:
- shared/utils/customFields.ts (types + pure helpers)
- composables/useCustomFieldInputs.ts (reactive composable)

Migrated all consumers:
- Backend: add defaultValue to API Platform serialization groups
- Standalone pages: component edit/create, piece edit/create, product edit/create/detail
- Machine page: MachineCustomFieldsCard, MachineInfoCard, useMachineDetailCustomFields
- Hierarchy: ComponentItem, PieceItem
- Shared: CustomFieldDisplay, CustomFieldInputGrid
- Category editor: componentStructure.ts

Deleted:
- entityCustomFieldLogic.ts (335 lines)
- customFieldUtils.ts (440 lines)
- customFieldFormUtils.ts (404 lines)
- useEntityCustomFields.ts (181 lines)
- customFieldFormUtils.test.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:20:08 +02:00
6d3cbf9157 docs : fix task ordering — category editor before machine page
normalizeStructureForEditor is used by useMachineDetailCustomFields.
Must clean it (Task 6) before migrating the machine page (Task 7).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:20:08 +02:00
464633a288 docs : update implementation plan with review fixes
Addresses 8 issues found by dual code review:
- Add readOnly + optionsText to CustomFieldInput type
- Replace computed with mutable ref + refresh() in composable
- Add metadata fallback for fields without customFieldId
- Add onValueCreated callback to keep parent reactive state in sync
- Merge Task 4+5 to avoid type mismatch in intermediate state
- Detail surgical refactoring of transformComponentCustomFields
- Define data contract for ComponentItem/PieceItem (pre-merged)
- Fix hasDisplayableValue to handle readOnly fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:20:08 +02:00
52e6912a1a docs : add custom fields simplification implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:20:08 +02:00
a9428f6bae docs : add custom fields simplification design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:20:08 +02:00
201485552a fix(ui) : remove legacy edit pages and history composables, unify create/edit forms
Consolidate create and edit pages into single create pages with edit mode support.
Remove obsolete catalog pages, history composables, and fix remaining code review issues.
Include migration to relink orphaned custom fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:19:50 +02:00
cfaf234419 fix(test) : disable rate limiter in test env — fixes 214 false auth failures
The login rate limiter (5 req/min) was triggering 429 on most tests
since each test creates its own authenticated client via POST /api/session/profile.
Set limit to 10000 in test env so the full suite can run unthrottled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:38:29 +02:00
244bfdc3e4 fix : code review — correct 15 issues across UX overhaul (phases 1-4)
Critical fixes:
- Make MigrateConstructeurLinks migration no-op (legacy tables already dropped)
- Add explicit ON CONFLICT (id) target in RestoreConstructeurLinks migration
- Replace N+1 queries with 4 bulk GROUP BY in ConstructeurStatsController
- Declare missing versionListRef template ref in machine detail page
- Add missing await on removeMachineDocument, cast activeTab as string

Important fixes:
- Add lang="ts" to ToastContainer and constructeurs page
- Type entityType as union in UsedInSection/useUsedIn
- Remove dead duration param from showError
- Update back-link props to new /catalogues/* URLs (3 pages)
- Replace raw error blocks with EmptyState in component/piece detail pages
- Type handleFillEntity params and machineInfoCardRef

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 18:26:05 +02:00
8a841832b2 docs : add full session summary for UX overhaul (phases 1-4) 2026-04-05 17:48:43 +02:00
6b8422fd03 fix(migration) : restore constructeur links from backup data — fallback for prod 2026-04-05 17:46:46 +02:00
7c2ad165e4 fix(migration) : migrate constructeur links from legacy M2M tables to new link entities 2026-04-05 17:29:33 +02:00
eef4b01d74 fix(api) : add priority to constructeurs/stats route to avoid {id} conflict 2026-04-04 19:30:55 +02:00
3a5860c83c fix(ui) : use correct component names without path prefix (EmptyState, UsedInSection) 2026-04-04 19:29:00 +02:00
ef4e208828 feat(ui) : enrich category related items modal with machine counts and navigation links 2026-04-04 17:31:34 +02:00
14ed38704f feat(api) : add machine count to category related items endpoint 2026-04-04 17:29:39 +02:00
8b02f821d3 feat(ui) : add UsedInSection showing reverse entity relationships on detail pages 2026-04-04 17:29:13 +02:00
4afbc8ba8a feat(api) : add constructeur stats endpoints with entity counts 2026-04-04 17:26:06 +02:00
b484a426e0 feat(api) : add /api/{entityType}/{id}/used-in endpoint for reverse entity lookups
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:25:43 +02:00
5b06e2ba51 feat(ui) : improve mobile responsiveness — breadcrumb truncation, tabs scroll, form grids 2026-04-04 17:25:36 +02:00
7f91b30bf6 feat(ui) : error toasts persist until dismissed, add progress bar on auto-dismiss toasts 2026-04-04 17:25:00 +02:00
8e0e3a3b33 fix(custom-fields) : fix resolvedStructure declaration order and remove duplicate in usePieceEdit 2026-04-04 17:20:14 +02:00
fea51fb66b fix(custom-fields) : fix declaration order of resolvedStructure in usePieceEdit 2026-04-04 17:18:52 +02:00
644b05c30a fix(ui) : add lang=ts to script tags using TypeScript syntax
ComponentItem, PieceItem, and machine/[id] used 'as string' type
assertions but were missing lang="ts" on their script tags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:16:58 +02:00
48beff753e feat(ui) : reorganize navbar by business domain — Catalogues + Administration 2026-04-04 17:14:02 +02:00
db6fd8f36a feat(ui) : add legacy URL redirects for old catalog and category routes 2026-04-04 17:13:23 +02:00
6a43f08df8 fix(ui) : phase 1 review fixes — machine context links, type annotations, double arrow
- Add ?from=machine&machineId=xxx query params to entity NuxtLinks in hierarchy
- Add useRoute + machineId computed to ComponentItem, PieceItem, MachineProductsCard
- Add :string type to confirmRemove* handlers in machine page
- Remove unicode arrow from DetailHeader backLabel (icon already provides it)
- Add !isEditMode guard on product links in MachineProductsCard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:12:20 +02:00
8a355aad11 feat(ui) : create unified catalog+category pages under /catalogues/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:10:19 +02:00
72c10ced40 feat(ui) : add contextual breadcrumb navigation 2026-04-04 17:08:17 +02:00
71cf131e56 feat(ui) : add tabs to piece and product detail pages 2026-04-04 17:06:09 +02:00
5b37404b9e feat(ui) : add tabs to product detail page 2026-04-04 17:05:30 +02:00
c6e1fce313 feat(ui) : add tabs to component detail page 2026-04-04 17:02:37 +02:00
63104dc155 feat(ui) : add tabs to piece detail page 2026-04-04 17:02:31 +02:00
2b96d20d56 feat(ui) : add tabs to machine page, compact header with site/reference badges 2026-04-04 17:00:02 +02:00
a8a3facec8 feat(ui) : create useUnsavedGuard composable for edit page navigation protection 2026-04-04 16:58:35 +02:00
54b3b03611 feat(ui) : create EntityTabs shared component for tabbed detail pages 2026-04-04 16:56:45 +02:00
6742da2fce feat(ui) : align entity detail pages — category links and version lists on all three 2026-04-04 16:42:53 +02:00
1963ce261d feat(ui) : replace div-inputs with plain text in entity detail pages read mode 2026-04-04 16:42:23 +02:00
a610284325 feat(ui) : add confirmation dialogs on all delete and unlink actions 2026-04-04 16:41:35 +02:00
239f417a35 feat(ui) : contextual back navigation in DetailHeader 2026-04-04 16:40:40 +02:00
4f13f7d301 feat(ui) : clickable entity links, site→machines links, DataTable fixedLayout
- Add NuxtLink on component/piece/product names in machine hierarchy
  (using composantId, pieceId, product.id)
- Make site machine count badges clickable → /machines?sites={id}
- Add opt-in fixedLayout prop and minWidth to DataTable columns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:39:21 +02:00
6716d31126 feat(ui) : add item counters to machine section titles 2026-04-04 16:33:08 +02:00
2b04860ea8 fix(custom-fields) : fix declaration order in useComponentEdit and useComponentCreate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:18:16 +02:00
894d522036 refactor(custom-fields) : unify 3 parallel implementations into 1 module
Replace ~2900 lines across 9 files with ~400 lines in 2 files:
- shared/utils/customFields.ts (types + pure helpers)
- composables/useCustomFieldInputs.ts (reactive composable)

Migrated all consumers:
- Backend: add defaultValue to API Platform serialization groups
- Standalone pages: component edit/create, piece edit/create, product edit/create/detail
- Machine page: MachineCustomFieldsCard, MachineInfoCard, useMachineDetailCustomFields
- Hierarchy: ComponentItem, PieceItem
- Shared: CustomFieldDisplay, CustomFieldInputGrid
- Category editor: componentStructure.ts

Deleted:
- entityCustomFieldLogic.ts (335 lines)
- customFieldUtils.ts (440 lines)
- customFieldFormUtils.ts (404 lines)
- useEntityCustomFields.ts (181 lines)
- customFieldFormUtils.test.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:09:27 +02:00
f2eff89e00 docs : fix task ordering — category editor before machine page
normalizeStructureForEditor is used by useMachineDetailCustomFields.
Must clean it (Task 6) before migrating the machine page (Task 7).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:49:41 +02:00
1348fa9963 docs : update implementation plan with review fixes
Addresses 8 issues found by dual code review:
- Add readOnly + optionsText to CustomFieldInput type
- Replace computed with mutable ref + refresh() in composable
- Add metadata fallback for fields without customFieldId
- Add onValueCreated callback to keep parent reactive state in sync
- Merge Task 4+5 to avoid type mismatch in intermediate state
- Detail surgical refactoring of transformComponentCustomFields
- Define data contract for ComponentItem/PieceItem (pre-merged)
- Fix hasDisplayableValue to handle readOnly fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:46:31 +02:00
875a34f169 docs : add custom fields simplification implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:37:15 +02:00
353d7e938e docs : add custom fields simplification design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:32:11 +02:00
gitea-actions
a6ca909a73 chore : bump version to v1.9.16
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-04 09:26:59 +00:00
2c1ddb2126 feat(infra) : add self-update mechanism to deploy.sh
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-04 11:26:49 +02:00
gitea-actions
c64b125047 chore : bump version to v1.9.15
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 39s
2026-04-04 09:23:14 +00:00
85c7c97dc3 feat(infra) : add pull-prod-db script and make target
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-04 11:22:34 +02:00
gitea-actions
1705a3688b chore : bump version to v1.9.14
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 2m2s
2026-04-03 14:23:58 +00:00
Matthieu
34b36f5d14 fix(infra) : add qpdf to production Docker image
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
qpdf was only installed in dev Dockerfile, causing the PDF compressor
to silently skip compression in production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:23:47 +02:00
gitea-actions
d6b74f01f9 chore : bump version to v1.9.13
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-04-03 13:53:38 +00:00
Matthieu
5efedfabf8 fix(infra) : fix document storage double nesting in prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Les fichiers documents étaient stockés sous storage/storage/documents/
au lieu de storage/ directement, causant des 404 en prod.
Le deploy script corrige la structure et le Dockerfile crée le
répertoire var/storage/documents dans l'image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:53:23 +02:00
gitea-actions
d0aba111b3 chore : bump version to v1.9.12
All checks were successful
Auto Tag Develop / tag (push) Successful in 10s
Build & Push Docker Image / build (push) Successful in 45s
2026-04-03 12:05:38 +00:00
6eaefdbbbf Merge branch 'feat/empty-slot-highlight' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-03 14:05:23 +02:00
b869984609 feat(infra) : ajout maintenance mode nginx-proxy + extraction maintenance.html au deploy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:56:05 +02:00
59fae38176 feat(infra) : ajout maintenance mode dans nginx-proxy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:51:10 +02:00
a674a5f2f0 feat(infra) : add maintenance mode support to production nginx config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:46:44 +02:00
0049638e3c fix(custom-fields) : context fields display, batch save, hierarchy propagation and UniqueEntity fix
- ComponentItem/PieceItem: DaisyUI divider, emit context-field-update for batch save
- CustomFieldDisplay: support editable/emit-blur/title/show-header props
- MachineComponentsCard/MachinePiecesCard: propagate custom-field-update events
- useMachineDetailCustomFields: pendingContextFieldUpdates + saveAllContextCustomFields
- useMachineDetailData: wire context field save into submitEdition
- useMachineDetailUpdates: only PATCH changed machine fields
- useMachineHierarchy: propagate contextCustomFields/Values from link to nodes
- componentStructure: include machineContextOnly in normalizeStructureForEditor
- Machine entity: convert empty reference to null, ignoreNull on UniqueEntity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:46:39 +02:00
c54e5c33f2 fix(custom-fields) : include machineContextOnly in ModelType.serializeCustomFields
The GET response for ModelType structure was missing machineContextOnly,
so on page reload the flag was always read as false by the frontend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:24:20 +02:00
51b491097e fix(custom-fields) : preserve machineContextOnly through sanitizeCustomFields
The sanitize layer was reconstructing the field object without machineContextOnly,
causing it to always be false in the save payload regardless of checkbox state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:22:38 +02:00
7da78b3b3e fix(custom-fields) : pass machineContextOnly through structure serialize/hydrate
- componentStructure: include machineContextOnly in save payload value object
- componentStructureHydrate: read machineContextOnly in hydrate and mapComponentCustomFields
- pieceProductStructure: include machineContextOnly in sanitize and hydrate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:13:36 +02:00
a88e4a68fb fix(custom-fields) : persist machineContextOnly in structure save and clone
- SkeletonStructureService: read and write machineContextOnly on create/update
- normalizeCustomFieldData: pass machineContextOnly through both payload formats
- cloneCustomFields: copy machineContextOnly flag on machine clone

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:09:15 +02:00
61584f2f9b fix(custom-fields) : fix typecheck errors on machineContextOnly filter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:03:24 +02:00
970708ea83 test(custom-fields) : add tests for machine context custom fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:01:23 +02:00
7b674fcc0c feat(custom-fields) : display context custom fields in PieceItem
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:00:28 +02:00
99147f4e08 feat(custom-fields) : display context custom fields in ComponentItem
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:59:48 +02:00
aec33e7911 feat(custom-fields) : clone context field values on machine clone
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:52:50 +02:00
2edb748bd4 feat(custom-fields) : support link-based upsert in CustomFieldValueController
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:52:18 +02:00
da12955b52 feat(custom-fields) : expose context custom fields in machine structure response
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:51:40 +02:00
1385d7768c feat(custom-fields) : filter machineContextOnly from standalone and machine-detail views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:51:32 +02:00
500b6b1620 feat(custom-fields) : add machineContextOnly toggle in structure editors
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:51:05 +02:00
54203db328 feat(custom-fields) : add machineContextOnly to custom field types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:50:33 +02:00
d085c48953 test(custom-fields) : extend factories for machineContextOnly and link params
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:50:29 +02:00
4f1f643436 feat(custom-fields) : add machineContextOnly flag and link FKs for machine context
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:49:01 +02:00
1c3b566923 feat(machines) : allow category-only links on machine structure
Enable adding a component, piece, or product to a machine by selecting
only the category (ModelType) without a specific entity. The link
displays a red "À remplir" badge; clicking it reopens the modal
pre-filled with the category so the user can associate an item later.

Backend: entity FKs made nullable on the 3 link tables, modelType FK
added, controller/audit/version/MCP normalization adapted for null
entities.

Frontend: modal accepts category-only confirm, page handles fill mode,
hierarchy builder creates pending nodes, display components show
clickable badge with event propagation through the full hierarchy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:15:47 +02:00
342ae37762 chore(makefile) : add missing make targets from SIRH/Lesstime
Add migration-migrate, shell-root, logs-dev targets and include
migration-migrate in the install dependency chain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:34:25 +02:00
Matthieu
1529d21f12 docs(custom-fields) : add spec and implementation plans for machine context custom fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:25:07 +02:00
Matthieu
d6441bef06 feat(ui) : highlight empty slots with category name in red
- Empty component slots (pieces, products, subcomponents) now display
  the category/type name with red styling instead of generic labels
- Machine view: empty structure pieces show type name + "manquant" in red
- Backend: include typePiece in structure slot data for name resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:21:25 +02:00
gitea-actions
3cf9db8829 chore : bump version to v1.9.11
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 41s
2026-04-02 12:36:04 +00:00
Matthieu
12c2b1e1b3 chore(infra) : remove release artefact pipeline
All checks were successful
Auto Tag Develop / tag (push) Successful in 11s
Keep only Docker-based deployment workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:35:51 +02:00
gitea-actions
b92c09cf55 chore : bump version to v1.9.10
Some checks failed
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 3m22s
Build Release Artefact / build (push) Failing after 1m42s
2026-04-02 10:05:05 +00:00
18cb9d5d80 refactor(infra) : reorganize docker config into infra/dev and infra/prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Align project structure with Lesstime: move docker/ to infra/dev/ and
deploy/ to infra/prod/. Update all references in docker-compose,
makefile, CI workflow, Dockerfile, .gitignore and .dockerignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:11:35 +02:00
gitea-actions
4ba134dd69 chore : bump version to v1.9.9
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 32s
Build Release Artefact / build (push) Failing after 1m27s
2026-04-01 14:18:42 +00:00
Matthieu
5e7a744151 feat : add maintenance mode toggle in admin panel
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
- Backend: MaintenanceModeListener blocks non-admin API requests when
  var/maintenance flag file exists. MaintenanceController provides
  toggle (PUT /api/admin/maintenance) and public check endpoint
  (GET /api/maintenance/check).
- Frontend: Toggle button in admin page, maintenance.vue page for
  blocked users, middleware redirects non-admins to /maintenance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:18:32 +02:00
gitea-actions
044b64152c chore : bump version to v1.9.8
Some checks failed
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 36s
Build Release Artefact / build (push) Failing after 1m38s
2026-04-01 12:52:57 +00:00
Matthieu
4de3ffa0e0 chore : trigger auto-tag
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
2026-04-01 14:52:45 +02:00
Matthieu
5bdf578de9 refactor : migrate VERSION file to config/version.yaml
Some checks failed
Auto Tag Develop / tag (push) Failing after 7s
Same versioning system as SIRH/Lesstime. Updates nuxt.config.ts,
Dockerfile, deploy.sh, auto-tag CI, and release script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:52:01 +02:00
gitea-actions
bc1b757a96 chore : bump version to v1.9.7
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 18s
Build Release Artefact / build (push) Successful in 2m53s
2026-04-01 12:47:15 +00:00
Matthieu
24b664e85b fix : update frontend/ to latest develop branch content
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
The initial merge only had master content. This replaces frontend/
with the full develop branch including reference-auto, constructeur
links, versioning, and all recent features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:47:04 +02:00
gitea-actions
8565e68062 chore : bump version to v1.9.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 23s
Build Release Artefact / build (push) Successful in 2m5s
2026-04-01 12:36:42 +00:00
Matthieu
a8a95b16a9 fix : mount var/storage/documents volume instead of var/uploads
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-01 14:36:30 +02:00
gitea-actions
68b394fc14 chore : bump version to v1.9.5
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 34s
Build Release Artefact / build (push) Successful in 1m41s
2026-04-01 12:28:32 +00:00
Matthieu
2ceb49db9f fix : use REGISTRY_TOKEN instead of RELEASE_TOKEN in CI workflows
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-01 14:28:20 +02:00
Matthieu
8ad0f26249 feat : add auto-tag and release artefact CI workflows
Some checks failed
Auto Tag Develop / tag (push) Failing after 4s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:23:12 +02:00
Matthieu
be859e57db refactor : rename Inventory_frontend to frontend in docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:19 +02:00
199 changed files with 19231 additions and 7568 deletions

View File

@@ -2,10 +2,10 @@
.gitea .gitea
.env.local .env.local
.env.test .env.test
docker/ infra/dev/
deploy/docker/docker-compose.prod.yml infra/prod/docker-compose.yml
deploy/docker/deploy.sh infra/prod/deploy.sh.example
deploy/docker/.env.example infra/prod/.env.example
frontend/node_modules frontend/node_modules
frontend/.nuxt frontend/.nuxt
frontend/.output frontend/.output

View File

@@ -0,0 +1,65 @@
name: Auto Tag Develop
on:
push:
branches:
- develop
jobs:
tag:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.REGISTRY_TOKEN }}
persist-credentials: true
- name: Create next tag from config/version.yaml
shell: bash
run: |
set -euo pipefail
# Skip if current commit already has a vX.Y.Z tag
if git tag --points-at HEAD | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag already exists on this commit. Skipping."
exit 0
fi
changed_version=false
if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^config/version\.yaml$'; then
changed_version=true
fi
read_version() {
awk -F': *' '/app\.version:/{print $2}' config/version.yaml | tr -d '[:space:]' | tr -d "'\""
}
if $changed_version; then
version="$(read_version)"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version in version.yaml: $version" >&2
exit 1
fi
else
last_tag="$(git tag -l 'v*' --sort=-v:refname | head -n1 || true)"
if [ -z "$last_tag" ]; then
version="0.1.0"
else
base="${last_tag#v}"
IFS='.' read -r major minor patch <<< "$base"
version="${major}.${minor}.$((patch + 1))"
fi
printf "parameters:\\n app.version: '%s'\\n" "$version" > config/version.yaml
git config user.name "gitea-actions"
git config user.email "gitea-actions@local"
git add config/version.yaml
git commit -m "chore : bump version to v$version" || true
git push origin develop || true
fi
tag="v$version"
git tag "$tag"
git push origin "$tag"

View File

@@ -19,7 +19,7 @@ jobs:
- name: Build Docker image - name: Build Docker image
run: | run: |
docker build \ docker build \
-f deploy/docker/Dockerfile.prod \ -f infra/prod/Dockerfile \
-t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \ -t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/inventory:latest \ -t gitea.malio.fr/malio-dev/inventory:latest \
. .

2
.gitignore vendored
View File

@@ -20,7 +20,7 @@
###< phpunit/phpunit ### ###< phpunit/phpunit ###
###> docker ### ###> docker ###
docker/.env.docker.local infra/dev/.env.docker.local
###< docker ### ###< docker ###
###> migration archives ### ###> migration archives ###
/_archives/ /_archives/

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

@@ -12,7 +12,7 @@ inventory.malio-dev.fr/api → Backend Symfony (PHP-FPM derrière Nginx)
| Composant | Technologie | Emplacement serveur | | Composant | Technologie | Emplacement serveur |
|-----------|-------------|---------------------| |-----------|-------------|---------------------|
| Backend | Symfony 8 + API Platform | `/var/www/Inventory/` | | Backend | Symfony 8 + API Platform | `/var/www/Inventory/` |
| Frontend | Nuxt 4 (site statique) | `/var/www/Inventory/Inventory_frontend/.output/public/` | | Frontend | Nuxt 4 (site statique) | `/var/www/Inventory/frontend/.output/public/` |
| Base de données | PostgreSQL 16 | Base `inventory` | | Base de données | PostgreSQL 16 | Base `inventory` |
### Schéma simplifié ### Schéma simplifié
@@ -117,7 +117,7 @@ php bin/console doctrine:migrations:migrate --no-interaction
### 4. Configurer le frontend Nuxt ### 4. Configurer le frontend Nuxt
```bash ```bash
cd /var/www/Inventory/Inventory_frontend cd /var/www/Inventory/frontend
# Permissions # Permissions
sudo chown -R malio:malio . sudo chown -R malio:malio .
@@ -173,7 +173,7 @@ server {
# Frontend statique — tout le reste # Frontend statique — tout le reste
location / { location / {
root /var/www/Inventory/Inventory_frontend/.output/public; root /var/www/Inventory/frontend/.output/public;
index index.html; index index.html;
try_files $uri $uri/ /index.html; # SPA fallback try_files $uri $uri/ /index.html; # SPA fallback
} }
@@ -214,7 +214,7 @@ php bin/console cache:clear --env=prod
sudo chown -R www-data:www-data var/ sudo chown -R www-data:www-data var/
# Frontend # Frontend
cd Inventory_frontend cd frontend
npm install npm install
npx nuxi generate npx nuxi generate
``` ```
@@ -268,7 +268,7 @@ php /var/www/Inventory/bin/console cache:clear --env=prod
Les fichiers statiques sont en cache. Rebuilder : Les fichiers statiques sont en cache. Rebuilder :
```bash ```bash
cd /var/www/Inventory/Inventory_frontend cd /var/www/Inventory/frontend
rm -rf .output rm -rf .output
npx nuxi generate npx nuxi generate
``` ```
@@ -299,7 +299,7 @@ tail -f /var/www/Inventory/var/log/prod.log
php /var/www/Inventory/bin/console cache:clear --env=prod php /var/www/Inventory/bin/console cache:clear --env=prod
# Rebuild frontend # Rebuild frontend
cd /var/www/Inventory/Inventory_frontend && npx nuxi generate cd /var/www/Inventory/frontend && npx nuxi generate
# Status des services # Status des services
systemctl status php8.4-fpm systemctl status php8.4-fpm

View File

@@ -57,7 +57,7 @@ make start
make install make install
``` ```
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`. > Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `infra/dev/.env.docker.local`.
### Que fait `make install` ? ### Que fait `make install` ?
@@ -254,7 +254,7 @@ Configuration PhpStorm / VSCode :
- **Port** : `8081` - **Port** : `8081`
- **Path mapping** : racine du projet → `/var/www/html` - **Path mapping** : racine du projet → `/var/www/html`
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `docker/.env.docker.local` avec votre IP locale. > Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `infra/dev/.env.docker.local` avec votre IP locale.
## Git ## Git

View File

@@ -59,7 +59,7 @@ Après avoir exécuté le script :
```bash ```bash
# Pousser le frontend d'abord (si modifié) # Pousser le frontend d'abord (si modifié)
cd Inventory_frontend && git push && git push --tags && cd .. cd frontend && git push && git push --tags && cd ..
# Pousser le backend # Pousser le backend
git push && git push --tags git push && git push --tags
@@ -79,7 +79,7 @@ git push && git push --tags
|---------|------| |---------|------|
| `VERSION` | Source unique de vérité | | `VERSION` | Source unique de vérité |
| `config/packages/api_platform.yaml` | Version affichée dans la doc API (Swagger) | | `config/packages/api_platform.yaml` | Version affichée dans la doc API (Swagger) |
| `Inventory_frontend/nuxt.config.ts` | Lit `VERSION` au build pour l'afficher dans le footer | | `frontend/nuxt.config.ts` | Lit `VERSION` au build pour l'afficher dans le footer |
| Footer de l'app | Affiche `v{{ appVersion }}` | | Footer de l'app | Affiche `v{{ appVersion }}` |
## Notes de release ## Notes de release
@@ -118,5 +118,5 @@ git submodule update --init --recursive
composer install --no-dev --optimize-autoloader composer install --no-dev --optimize-autoloader
php bin/console doctrine:migrations:migrate --no-interaction php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear --env=prod php bin/console cache:clear --env=prod
cd Inventory_frontend && npm install && npx nuxi generate cd frontend && npm install && npx nuxi generate
``` ```

View File

@@ -1 +0,0 @@
1.9.4

View File

@@ -23,6 +23,8 @@
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*", "symfony/framework-bundle": "8.0.*",
"symfony/mcp-bundle": "^0.6.0", "symfony/mcp-bundle": "^0.6.0",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0.2",
"symfony/property-access": "8.0.*", "symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*", "symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*", "symfony/rate-limiter": "8.0.*",

434
composer.lock generated
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

@@ -1,7 +1,7 @@
api_platform: api_platform:
title: Inventory API title: Inventory API
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits. description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
version: 1.9.1 version: 1.9.6
defaults: defaults:
stateless: false stateless: false
cache_headers: cache_headers:

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

@@ -8,3 +8,15 @@ framework:
policy: sliding_window policy: sliding_window
limit: 5 limit: 5
interval: '1 minute' interval: '1 minute'
when@test:
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 10000
interval: '1 minute'
login:
policy: sliding_window
limit: 10000
interval: '1 minute'

View File

@@ -55,6 +55,7 @@ security:
- { path: ^/api/admin, roles: ROLE_ADMIN } - { path: ^/api/admin, roles: ROLE_ADMIN }
- { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/health$, roles: PUBLIC_ACCESS } - { path: ^/api/health$, roles: PUBLIC_ACCESS }
- { path: ^/api/maintenance/check$, roles: PUBLIC_ACCESS }
- { path: ^/_mcp, roles: ROLE_USER } - { path: ^/_mcp, roles: ROLE_USER }
- { path: ^/docs, roles: PUBLIC_ACCESS } - { path: ^/docs, roles: PUBLIC_ACCESS }
- { path: ^/contexts, roles: PUBLIC_ACCESS } - { path: ^/contexts, roles: PUBLIC_ACCESS }

2
config/version.yaml Normal file
View File

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

View File

@@ -1,28 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export INVENTORY_IMAGE_TAG="$TAG"
echo "==> Deploying inventory:${TAG}..."
echo "==> Pulling image..."
sudo docker compose pull
echo "==> Starting container..."
sudo docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
VERSION=$(sudo docker compose exec -T app cat VERSION)
echo "==> Deployed v${VERSION}"

View File

@@ -1,13 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name inventory.malio-dev.fr;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -2,7 +2,7 @@ services:
web: web:
container_name: php-${DOCKER_APP_NAME}-apache container_name: php-${DOCKER_APP_NAME}-apache
build: build:
context: ./docker/php context: ./infra/dev
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION} DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
@@ -20,9 +20,9 @@ services:
- ~/.cache:/var/www/.cache # Pour la cache de composer - ~/.cache:/var/www/.cache # Pour la cache de composer
- ~/.config:/var/www/.config # Pour la config de yarn - ~/.config:/var/www/.config # Pour la config de yarn
- ~/.composer:/var/www/.composer # Pour la config de composer - ~/.composer:/var/www/.composer # Pour la config de composer
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini - ./infra/dev/php.ini:/usr/local/etc/php/php.ini
- ./docker/php/config/vhost.conf:/etc/apache2/sites-available/000-default.conf - ./infra/dev/vhost.conf:/etc/apache2/sites-available/000-default.conf
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG - ./LOG:/var/www/html/LOG
- ./LOG/logs_apache:/var/log/apache2/ - ./LOG/logs_apache:/var/log/apache2/
extra_hosts: extra_hosts:

View File

@@ -103,10 +103,10 @@ Cela securise l'integrite sans changer l'architecture. Le `resolveTarget` et les
### 3. Composables frontend geants (400-550 LOC) ### 3. Composables frontend geants (400-550 LOC)
**Fichiers concernes :** **Fichiers concernes :**
- `/Inventory_frontend/app/composables/useComponentEdit.ts` (550 LOC) - `/frontend/app/composables/useComponentEdit.ts` (550 LOC)
- `/Inventory_frontend/app/composables/usePieceEdit.ts` (472 LOC) - `/frontend/app/composables/usePieceEdit.ts` (472 LOC)
- `/Inventory_frontend/app/composables/useMachineDetailData.ts` (468 LOC) - `/frontend/app/composables/useMachineDetailData.ts` (468 LOC)
- `/Inventory_frontend/app/composables/useComponentCreate.ts` (417 LOC) - `/frontend/app/composables/useComponentCreate.ts` (417 LOC)
**Probleme :** Ces composables orchestrent en un seul fichier : le chargement de donnees, la gestion de formulaire, la persistence des custom fields, la gestion des documents, l'historique, la resolution de labels, et la soumission. Chacun instancie 8-12 sous-composables. **Probleme :** Ces composables orchestrent en un seul fichier : le chargement de donnees, la gestion de formulaire, la persistence des custom fields, la gestion des documents, l'historique, la resolution de labels, et la soumission. Chacun instancie 8-12 sous-composables.
@@ -134,9 +134,9 @@ Appliquer le meme pattern a `usePieceEdit` et `useComponentCreate`. Les blocs co
### 4. Triple duplication de la logique custom fields frontend ### 4. Triple duplication de la logique custom fields frontend
**Fichiers concernes :** **Fichiers concernes :**
- `/Inventory_frontend/app/shared/utils/customFieldFormUtils.ts` (404 LOC) - pour les pages create/edit - `/frontend/app/shared/utils/customFieldFormUtils.ts` (404 LOC) - pour les pages create/edit
- `/Inventory_frontend/app/shared/utils/customFieldUtils.ts` (440 LOC) - pour la page machine detail - `/frontend/app/shared/utils/customFieldUtils.ts` (440 LOC) - pour la page machine detail
- `/Inventory_frontend/app/shared/utils/entityCustomFieldLogic.ts` (335 LOC) - pour les composants item - `/frontend/app/shared/utils/entityCustomFieldLogic.ts` (335 LOC) - pour les composants item
**Probleme :** Ces 3 fichiers resolvent le meme probleme (normaliser des definitions de custom fields + merger avec des valeurs existantes) avec des implementations differentes : **Probleme :** Ces 3 fichiers resolvent le meme probleme (normaliser des definitions de custom fields + merger avec des valeurs existantes) avec des implementations differentes :
- `customFieldFormUtils.ts` : `resolveFieldName()`, `resolveFieldType()`, `buildCustomFieldInputs()` - `customFieldFormUtils.ts` : `resolveFieldName()`, `resolveFieldType()`, `buildCustomFieldInputs()`
@@ -273,7 +273,7 @@ public function process(mixed $data, Operation $operation, ...): mixed
### 9. Dependance circulaire dans `useMachineDetailData` ### 9. Dependance circulaire dans `useMachineDetailData`
**Fichier concerne :** **Fichier concerne :**
- `/Inventory_frontend/app/composables/useMachineDetailData.ts` (lignes 119-187) - `/frontend/app/composables/useMachineDetailData.ts` (lignes 119-187)
**Probleme :** `useMachineDetailProducts` a besoin de `machineProductLinks` (venant de hierarchy), et `useMachineDetailHierarchy` a besoin de `findProductById` (venant de products). La solution actuelle utilise un `_machineProductLinksProxy` ref avec un watcher pour synchroniser. **Probleme :** `useMachineDetailProducts` a besoin de `machineProductLinks` (venant de hierarchy), et `useMachineDetailHierarchy` a besoin de `findProductById` (venant de products). La solution actuelle utilise un `_machineProductLinksProxy` ref avec un watcher pour synchroniser.

View File

@@ -811,9 +811,9 @@ private function resolvePieceQuantity(MachinePieceLink $pieceLink): int
### Task 2.5: Update Frontend to Handle New Structure Format ### Task 2.5: Update Frontend to Handle New Structure Format
**Files:** **Files:**
- Modify: `Inventory_frontend/app/composables/useMachineHierarchy.ts``buildMachineHierarchyFromLinks()` - Modify: `frontend/app/composables/useMachineHierarchy.ts``buildMachineHierarchyFromLinks()`
- Modify: `Inventory_frontend/app/shared/utils/structureDisplayUtils.ts` - Modify: `frontend/app/shared/utils/structureDisplayUtils.ts`
- Modify: `Inventory_frontend/app/shared/utils/structureSelectionUtils.ts` - Modify: `frontend/app/shared/utils/structureSelectionUtils.ts`
**Note:** The API response shape for `structure` stays the same (pieces/subcomponents/products arrays), so frontend changes should be minimal. The main change is that `path` fields are removed and `resolvedPiece` is now always populated inline. **Note:** The API response shape for `structure` stays the same (pieces/subcomponents/products arrays), so frontend changes should be minimal. The main change is that `path` fields are removed and `resolvedPiece` is now always populated inline.
@@ -950,7 +950,7 @@ ALTER TABLE pieces DROP COLUMN IF EXISTS productids;
### Task 4.1: Update Frontend Types ### Task 4.1: Update Frontend Types
**Files:** **Files:**
- Modify: `Inventory_frontend/app/shared/types/` (if type definitions reference structure/skeleton JSON shapes) - Modify: `frontend/app/shared/types/` (if type definitions reference structure/skeleton JSON shapes)
- [ ] **Step 1: Search frontend for all references to `structure.pieces`, `structure.subcomponents`, `structure.products`, `skeleton`, `productIds`** - [ ] **Step 1: Search frontend for all references to `structure.pieces`, `structure.subcomponents`, `structure.products`, `skeleton`, `productIds`**

View File

@@ -274,15 +274,15 @@ git commit -m "test(piece) : add quantity tests for MachinePieceLink"
### Task 4: TypeScript Types + Sanitization + Hydration Functions ### Task 4: TypeScript Types + Sanitization + Hydration Functions
**Files:** **Files:**
- Modify: `Inventory_frontend/app/shared/types/inventory.ts` - Modify: `frontend/app/shared/types/inventory.ts`
- Modify: `Inventory_frontend/app/shared/model/componentStructure.ts` - Modify: `frontend/app/shared/model/componentStructure.ts`
- Modify: `Inventory_frontend/app/shared/model/componentStructureSanitize.ts` - Modify: `frontend/app/shared/model/componentStructureSanitize.ts`
- Modify: `Inventory_frontend/app/shared/model/componentStructureHydrate.ts` - Modify: `frontend/app/shared/model/componentStructureHydrate.ts`
- Modify: `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts` - Modify: `frontend/app/shared/utils/structureAssignmentHelpers.ts`
- [ ] **Step 1: Add `quantity` to `ComponentModelPiece` type** - [ ] **Step 1: Add `quantity` to `ComponentModelPiece` type**
In `Inventory_frontend/app/shared/types/inventory.ts`, add `quantity` to the `ComponentModelPiece` interface (after `role`, line ~23): In `frontend/app/shared/types/inventory.ts`, add `quantity` to the `ComponentModelPiece` interface (after `role`, line ~23):
```typescript ```typescript
quantity?: number quantity?: number
@@ -290,7 +290,7 @@ quantity?: number
- [ ] **Step 2: Add `quantity` to `validatePiece()` in same file** - [ ] **Step 2: Add `quantity` to `validatePiece()` in same file**
In `Inventory_frontend/app/shared/types/inventory.ts`, in `validatePiece()` (line ~144-172): In `frontend/app/shared/types/inventory.ts`, in `validatePiece()` (line ~144-172):
After `const role = ensureString(value.role)` (line ~161), add: After `const role = ensureString(value.role)` (line ~161), add:
@@ -306,7 +306,7 @@ And in the return object, add after the `role` spread:
- [ ] **Step 3: Update `sanitizePieces()` to preserve quantity** - [ ] **Step 3: Update `sanitizePieces()` to preserve quantity**
In `Inventory_frontend/app/shared/model/componentStructureSanitize.ts`, in `sanitizePieces()` (~line 130-188). In `frontend/app/shared/model/componentStructureSanitize.ts`, in `sanitizePieces()` (~line 130-188).
After the existing field extractions, add: After the existing field extractions, add:
@@ -324,7 +324,7 @@ if (quantity !== undefined) {
- [ ] **Step 4: Update `normalizeStructureForSave()` to include quantity** - [ ] **Step 4: Update `normalizeStructureForSave()` to include quantity**
In `Inventory_frontend/app/shared/model/componentStructure.ts`, in `normalizeStructureForSave()` (~lines 164-179), add in the piece payload mapping after the `reference` check: In `frontend/app/shared/model/componentStructure.ts`, in `normalizeStructureForSave()` (~lines 164-179), add in the piece payload mapping after the `reference` check:
```typescript ```typescript
if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) { if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) {
@@ -336,7 +336,7 @@ if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) {
- [ ] **Step 5: Update `hydratePieces()` and `mapComponentPieces()` to preserve quantity** - [ ] **Step 5: Update `hydratePieces()` and `mapComponentPieces()` to preserve quantity**
In `Inventory_frontend/app/shared/model/componentStructureHydrate.ts`: In `frontend/app/shared/model/componentStructureHydrate.ts`:
In `hydratePieces()` (line ~95-107), add to the mapped object: In `hydratePieces()` (line ~95-107), add to the mapped object:
@@ -352,7 +352,7 @@ In `mapComponentPieces()` (line ~168-179), add to the mapped object:
- [ ] **Step 6: Update `sanitizePieceDefinition()` to preserve quantity** - [ ] **Step 6: Update `sanitizePieceDefinition()` to preserve quantity**
In `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts`, in `sanitizePieceDefinition()` (~lines 172-180), add to the `stripNullish()` object: In `frontend/app/shared/utils/structureAssignmentHelpers.ts`, in `sanitizePieceDefinition()` (~lines 172-180), add to the `stripNullish()` object:
```typescript ```typescript
quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null, quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null,
@@ -361,14 +361,14 @@ quantity: typeof (definition as any).quantity === 'number' ? (definition as any)
- [ ] **Step 7: Run lint + typecheck** - [ ] **Step 7: Run lint + typecheck**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck cd frontend && npm run lint:fix && npx nuxi typecheck
``` ```
Expected: 0 errors Expected: 0 errors
- [ ] **Step 8: Commit** - [ ] **Step 8: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/shared/types/inventory.ts app/shared/model/componentStructure.ts app/shared/model/componentStructureSanitize.ts app/shared/model/componentStructureHydrate.ts app/shared/utils/structureAssignmentHelpers.ts git add app/shared/types/inventory.ts app/shared/model/componentStructure.ts app/shared/model/componentStructureSanitize.ts app/shared/model/componentStructureHydrate.ts app/shared/utils/structureAssignmentHelpers.ts
git commit -m "feat(piece) : add quantity field to piece types, sanitization and hydration" git commit -m "feat(piece) : add quantity field to piece types, sanitization and hydration"
``` ```
@@ -378,14 +378,14 @@ git commit -m "feat(piece) : add quantity field to piece types, sanitization and
### Task 5: Composant Structure Editor — Quantity Input ### Task 5: Composant Structure Editor — Quantity Input
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/StructureNodeEditor.vue` (piece section, lines ~229-299) - Modify: `frontend/app/components/StructureNodeEditor.vue` (piece section, lines ~229-299)
- Modify: `Inventory_frontend/app/composables/useStructureNodeCrud.ts` (`addPiece()`, lines ~110-118) - Modify: `frontend/app/composables/useStructureNodeCrud.ts` (`addPiece()`, lines ~110-118)
**Context:** `StructureNodeEditor.vue` renders the composant structure editor. The piece section (lines ~236-293) currently shows only a `select` for `typePieceId` and a delete button. The `addPiece()` function in `useStructureNodeCrud.ts` creates new piece entries with default fields. **Context:** `StructureNodeEditor.vue` renders the composant structure editor. The piece section (lines ~236-293) currently shows only a `select` for `typePieceId` and a delete button. The `addPiece()` function in `useStructureNodeCrud.ts` creates new piece entries with default fields.
- [ ] **Step 1: Add default quantity to `addPiece()`** - [ ] **Step 1: Add default quantity to `addPiece()`**
In `Inventory_frontend/app/composables/useStructureNodeCrud.ts`, in `addPiece()` (line ~110-118), add `quantity: 1` to the pushed object: In `frontend/app/composables/useStructureNodeCrud.ts`, in `addPiece()` (line ~110-118), add `quantity: 1` to the pushed object:
```typescript ```typescript
const addPiece = () => { const addPiece = () => {
@@ -403,7 +403,7 @@ const addPiece = () => {
- [ ] **Step 2: Add quantity input in `StructureNodeEditor.vue`** - [ ] **Step 2: Add quantity input in `StructureNodeEditor.vue`**
In `Inventory_frontend/app/components/StructureNodeEditor.vue`, in the piece item rendering section (inside the `v-for` loop for pieces, line ~256-292), add a quantity input next to the existing piece type `select`. Place it after the select and before the delete button: In `frontend/app/components/StructureNodeEditor.vue`, in the piece item rendering section (inside the `v-for` loop for pieces, line ~256-292), add a quantity input next to the existing piece type `select`. Place it after the select and before the delete button:
```vue ```vue
<input <input
@@ -420,14 +420,14 @@ In `Inventory_frontend/app/components/StructureNodeEditor.vue`, in the piece ite
- [ ] **Step 3: Run lint + typecheck** - [ ] **Step 3: Run lint + typecheck**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck cd frontend && npm run lint:fix && npx nuxi typecheck
``` ```
Expected: 0 errors Expected: 0 errors
- [ ] **Step 4: Commit** - [ ] **Step 4: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/components/StructureNodeEditor.vue app/composables/useStructureNodeCrud.ts git add app/components/StructureNodeEditor.vue app/composables/useStructureNodeCrud.ts
git commit -m "feat(piece) : add quantity input to composant structure editor" git commit -m "feat(piece) : add quantity input to composant structure editor"
``` ```
@@ -437,13 +437,13 @@ git commit -m "feat(piece) : add quantity input to composant structure editor"
### Task 6: Machine Detail Page — Display Quantity ### Task 6: Machine Detail Page — Display Quantity
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/PieceItem.vue` - Modify: `frontend/app/components/PieceItem.vue`
**Context:** `PieceItem.vue` renders each piece in the machine structure view. The piece name is displayed at line ~26 in an `<h3>` tag. Quantity should appear as "×N" after the name, in secondary text. For direct pieces (no parent component), it should be editable. For composant pieces, read-only. **Context:** `PieceItem.vue` renders each piece in the machine structure view. The piece name is displayed at line ~26 in an `<h3>` tag. Quantity should appear as "×N" after the name, in secondary text. For direct pieces (no parent component), it should be editable. For composant pieces, read-only.
- [ ] **Step 1: Add quantity display to PieceItem** - [ ] **Step 1: Add quantity display to PieceItem**
In `Inventory_frontend/app/components/PieceItem.vue`, after the piece name in the `<h3>` tag (line ~26), add the quantity display: In `frontend/app/components/PieceItem.vue`, after the piece name in the `<h3>` tag (line ~26), add the quantity display:
```vue ```vue
<span <span
@@ -492,14 +492,14 @@ Ensure this value is included in the data emitted when saving (follow the same p
- [ ] **Step 3: Run lint + typecheck** - [ ] **Step 3: Run lint + typecheck**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck cd frontend && npm run lint:fix && npx nuxi typecheck
``` ```
Expected: 0 errors Expected: 0 errors
- [ ] **Step 4: Commit** - [ ] **Step 4: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/components/PieceItem.vue git add app/components/PieceItem.vue
git commit -m "feat(piece) : display and edit quantity on machine piece items" git commit -m "feat(piece) : display and edit quantity on machine piece items"
``` ```
@@ -514,14 +514,14 @@ git commit -m "feat(piece) : display and edit quantity on machine piece items"
- [ ] **Step 1: Push frontend commits** - [ ] **Step 1: Push frontend commits**
```bash ```bash
cd Inventory_frontend && git push cd frontend && git push
``` ```
- [ ] **Step 2: Update submodule pointer in main repo** - [ ] **Step 2: Update submodule pointer in main repo**
```bash ```bash
cd /home/matthieu/dev_malio/Inventory cd /home/matthieu/dev_malio/Inventory
git add Inventory_frontend git add frontend
git commit -m "chore(frontend) : update submodule — piece quantity feature" git commit -m "chore(frontend) : update submodule — piece quantity feature"
``` ```

View File

@@ -1346,13 +1346,13 @@ git commit -m "feat(sync) : add ModelTypeSyncController with preview and sync en
### Task 12: Delete `useCategoryEditGuard` composable and tests ### Task 12: Delete `useCategoryEditGuard` composable and tests
**Files:** **Files:**
- Delete: `Inventory_frontend/app/composables/useCategoryEditGuard.ts` - Delete: `frontend/app/composables/useCategoryEditGuard.ts`
- Delete: `Inventory_frontend/tests/composables/useCategoryEditGuard.test.ts` - Delete: `frontend/tests/composables/useCategoryEditGuard.test.ts`
- [ ] **Step 1: Delete files + commit** - [ ] **Step 1: Delete files + commit**
```bash ```bash
cd Inventory_frontend cd frontend
rm app/composables/useCategoryEditGuard.ts tests/composables/useCategoryEditGuard.test.ts rm app/composables/useCategoryEditGuard.ts tests/composables/useCategoryEditGuard.test.ts
git add -A && git commit -m "refactor(sync) : remove useCategoryEditGuard composable and tests" git add -A && git commit -m "refactor(sync) : remove useCategoryEditGuard composable and tests"
``` ```
@@ -1362,25 +1362,25 @@ git add -A && git commit -m "refactor(sync) : remove useCategoryEditGuard compos
### Task 13: Remove restrictedMode from structure editors and composables ### Task 13: Remove restrictedMode from structure editors and composables
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/StructureNodeEditor.vue` — remove `restrictedMode` prop, `v-if="!restrictedMode"` guards - Modify: `frontend/app/components/StructureNodeEditor.vue` — remove `restrictedMode` prop, `v-if="!restrictedMode"` guards
- Modify: `Inventory_frontend/app/components/PieceModelStructureEditor.vue` — same - Modify: `frontend/app/components/PieceModelStructureEditor.vue` — same
- Modify: `Inventory_frontend/app/components/ComponentModelStructureEditor.vue` — remove prop forwarding - Modify: `frontend/app/components/ComponentModelStructureEditor.vue` — remove prop forwarding
- Modify: `Inventory_frontend/app/composables/useStructureNodeCrud.ts` — remove `restrictedMode` from props, remove `isExisting*` guards, remove `initial*Indices` - Modify: `frontend/app/composables/useStructureNodeCrud.ts` — remove `restrictedMode` from props, remove `isExisting*` guards, remove `initial*Indices`
- Modify: `Inventory_frontend/app/composables/useStructureNodeLogic.ts` — remove from props, computed, and crud call - Modify: `frontend/app/composables/useStructureNodeLogic.ts` — remove from props, computed, and crud call
- Modify: `Inventory_frontend/app/composables/usePieceStructureEditorLogic.ts` — remove from props, remove `isExisting*` guards - Modify: `frontend/app/composables/usePieceStructureEditorLogic.ts` — remove from props, remove `isExisting*` guards
- [ ] **Step 1: Remove from each file** (read each file first, then edit) - [ ] **Step 1: Remove from each file** (read each file first, then edit)
- [ ] **Step 2: Run lint + typecheck** - [ ] **Step 2: Run lint + typecheck**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck cd frontend && npm run lint:fix && npx nuxi typecheck
``` ```
- [ ] **Step 3: Commit** - [ ] **Step 3: Commit**
```bash ```bash
cd Inventory_frontend && git add -A && git commit -m "refactor(sync) : remove restrictedMode from structure editors and composables" cd frontend && git add -A && git commit -m "refactor(sync) : remove restrictedMode from structure editors and composables"
``` ```
--- ---
@@ -1388,24 +1388,24 @@ cd Inventory_frontend && git add -A && git commit -m "refactor(sync) : remove re
### Task 14: Remove restrictedMode from ModelTypeForm and edit pages ### Task 14: Remove restrictedMode from ModelTypeForm and edit pages
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/model-types/ModelTypeForm.vue` — remove `restrictedMode`, `disableSubmit`, `disableSubmitMessage`, `restrictedModeMessage` props and warning banner - Modify: `frontend/app/components/model-types/ModelTypeForm.vue` — remove `restrictedMode`, `disableSubmit`, `disableSubmitMessage`, `restrictedModeMessage` props and warning banner
- Modify: `Inventory_frontend/app/pages/component-category/[id]/edit.vue` — remove `useCategoryEditGuard` import/usage, guard props from `<ModelTypeForm>` - Modify: `frontend/app/pages/component-category/[id]/edit.vue` — remove `useCategoryEditGuard` import/usage, guard props from `<ModelTypeForm>`
- Modify: `Inventory_frontend/app/pages/piece-category/[id]/edit.vue` — same - Modify: `frontend/app/pages/piece-category/[id]/edit.vue` — same
- Modify: `Inventory_frontend/app/pages/product-category/[id]/edit.vue` — same - Modify: `frontend/app/pages/product-category/[id]/edit.vue` — same
- Modify: `Inventory_frontend/tests/components/PieceModelStructureEditor.test.ts` — remove `restrictedMode: true` test cases - Modify: `frontend/tests/components/PieceModelStructureEditor.test.ts` — remove `restrictedMode: true` test cases
- [ ] **Step 1: Clean each file** (read first, then edit) - [ ] **Step 1: Clean each file** (read first, then edit)
- [ ] **Step 2: Run lint + typecheck** - [ ] **Step 2: Run lint + typecheck**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck cd frontend && npm run lint:fix && npx nuxi typecheck
``` ```
- [ ] **Step 3: Commit** - [ ] **Step 3: Commit**
```bash ```bash
cd Inventory_frontend && git add -A && git commit -m "refactor(sync) : remove restrictedMode from ModelTypeForm and category edit pages" cd frontend && git add -A && git commit -m "refactor(sync) : remove restrictedMode from ModelTypeForm and category edit pages"
``` ```
--- ---
@@ -1415,11 +1415,11 @@ cd Inventory_frontend && git add -A && git commit -m "refactor(sync) : remove re
### Task 15: Add sync service functions ### Task 15: Add sync service functions
**Files:** **Files:**
- Modify: `Inventory_frontend/app/services/modelTypes.ts` - Modify: `frontend/app/services/modelTypes.ts`
- [ ] **Step 1: Add `syncPreview` and `syncExecute`** - [ ] **Step 1: Add `syncPreview` and `syncExecute`**
Add to the end of `Inventory_frontend/app/services/modelTypes.ts`: Add to the end of `frontend/app/services/modelTypes.ts`:
```typescript ```typescript
export function syncPreview(id: string, structure: unknown, opts: { signal?: AbortSignal } = {}) { export function syncPreview(id: string, structure: unknown, opts: { signal?: AbortSignal } = {}) {
@@ -1466,7 +1466,7 @@ export function syncExecute(id: string, confirmation: { confirmDeletions: boolea
- [ ] **Step 2: Run lint + typecheck + commit** - [ ] **Step 2: Run lint + typecheck + commit**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck cd frontend && npm run lint:fix && npx nuxi typecheck
git add -A && git commit -m "feat(sync) : add syncPreview and syncExecute service functions" git add -A && git commit -m "feat(sync) : add syncPreview and syncExecute service functions"
``` ```
@@ -1475,7 +1475,7 @@ git add -A && git commit -m "feat(sync) : add syncPreview and syncExecute servic
### Task 16: Create SyncConfirmationModal component ### Task 16: Create SyncConfirmationModal component
**Files:** **Files:**
- Create: `Inventory_frontend/app/components/SyncConfirmationModal.vue` - Create: `frontend/app/components/SyncConfirmationModal.vue`
- [ ] **Step 1: Create the modal** - [ ] **Step 1: Create the modal**
@@ -1487,7 +1487,7 @@ Emits: `confirm`, `cancel`
- [ ] **Step 2: Run lint + typecheck + commit** - [ ] **Step 2: Run lint + typecheck + commit**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck cd frontend && npm run lint:fix && npx nuxi typecheck
git add -A && git commit -m "feat(sync) : add SyncConfirmationModal component" git add -A && git commit -m "feat(sync) : add SyncConfirmationModal component"
``` ```
@@ -1496,9 +1496,9 @@ git add -A && git commit -m "feat(sync) : add SyncConfirmationModal component"
### Task 17: Wire sync flow into category edit pages ### Task 17: Wire sync flow into category edit pages
**Files:** **Files:**
- Modify: `Inventory_frontend/app/pages/component-category/[id]/edit.vue` - Modify: `frontend/app/pages/component-category/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/piece-category/[id]/edit.vue` - Modify: `frontend/app/pages/piece-category/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/product-category/[id]/edit.vue` - Modify: `frontend/app/pages/product-category/[id]/edit.vue`
- [ ] **Step 1: Update `component-category/[id]/edit.vue`** - [ ] **Step 1: Update `component-category/[id]/edit.vue`**
@@ -1524,13 +1524,13 @@ Same flow, adapt imports and routes.
- [ ] **Step 4: Run lint + typecheck + build** - [ ] **Step 4: Run lint + typecheck + build**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck && npm run build cd frontend && npm run lint:fix && npx nuxi typecheck && npm run build
``` ```
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
cd Inventory_frontend && git add -A && git commit -m "feat(sync) : wire sync flow into category edit pages with confirmation modal" cd frontend && git add -A && git commit -m "feat(sync) : wire sync flow into category edit pages with confirmation modal"
``` ```
--- ---
@@ -1547,7 +1547,7 @@ Expected: All tests pass.
- [ ] **Step 2: Run frontend checks** - [ ] **Step 2: Run frontend checks**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck && npm run build cd frontend && npm run lint:fix && npx nuxi typecheck && npm run build
``` ```
- [ ] **Step 3: Run php-cs-fixer** - [ ] **Step 3: Run php-cs-fixer**
@@ -1561,7 +1561,7 @@ Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 1: Update frontend submodule** - [ ] **Step 1: Update frontend submodule**
```bash ```bash
git add Inventory_frontend git add frontend
git commit -m "chore(submodule) : update frontend pointer for sync feature" git commit -m "chore(submodule) : update frontend pointer for sync feature"
``` ```

View File

@@ -574,7 +574,7 @@ git commit -m "test(comments) : add tests for comment creation with file attachm
### Task 6: Frontend — update useComments composable ### Task 6: Frontend — update useComments composable
**Files:** **Files:**
- Modify: `Inventory_frontend/app/composables/useComments.ts` - Modify: `frontend/app/composables/useComments.ts`
- [ ] **Step 1: Add document type to Comment interface** - [ ] **Step 1: Add document type to Comment interface**
@@ -661,12 +661,12 @@ const createComment = async (
- [ ] **Step 3: Run lint + typecheck** - [ ] **Step 3: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck` Run: `cd frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 4: Commit (in frontend submodule)** - [ ] **Step 4: Commit (in frontend submodule)**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/composables/useComments.ts git add app/composables/useComments.ts
git commit -m "feat(comments) : support file attachments in createComment" git commit -m "feat(comments) : support file attachments in createComment"
``` ```
@@ -676,7 +676,7 @@ git commit -m "feat(comments) : support file attachments in createComment"
### Task 7: Frontend — update CommentSection.vue ### Task 7: Frontend — update CommentSection.vue
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/CommentSection.vue` - Modify: `frontend/app/components/CommentSection.vue`
- [ ] **Step 1: Add file input and file list display to the template** - [ ] **Step 1: Add file input and file list display to the template**
@@ -810,12 +810,12 @@ const handleSubmit = async () => {
- [ ] **Step 3: Run lint + typecheck** - [ ] **Step 3: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck` Run: `cd frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 4: Commit (in frontend submodule)** - [ ] **Step 4: Commit (in frontend submodule)**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/components/CommentSection.vue git add app/components/CommentSection.vue
git commit -m "feat(comments) : add file attachment UI to CommentSection" git commit -m "feat(comments) : add file attachment UI to CommentSection"
``` ```
@@ -848,7 +848,7 @@ git commit -m "feat(documents) : add comment ExistsFilter"
- [ ] **Step 4: Update submodule pointer** - [ ] **Step 4: Update submodule pointer**
```bash ```bash
git add Inventory_frontend git add frontend
git commit -m "chore(submodule) : update frontend pointer (comment documents feature)" git commit -m "chore(submodule) : update frontend pointer (comment documents feature)"
``` ```

View File

@@ -22,15 +22,15 @@
- `src/Controller/DocumentQueryController.php` — add `type` to `normalizeDocuments()` - `src/Controller/DocumentQueryController.php` — add `type` to `normalizeDocuments()`
### Frontend (create) ### Frontend (create)
- `Inventory_frontend/app/shared/documentTypes.ts` — type constants + labels - `frontend/app/shared/documentTypes.ts` — type constants + labels
- `Inventory_frontend/app/components/DocumentEditModal.vue` — mini-modal for editing name+type - `frontend/app/components/DocumentEditModal.vue` — mini-modal for editing name+type
### Frontend (modify) ### Frontend (modify)
- `Inventory_frontend/app/composables/useDocuments.ts` — add `type` to interface + `updateDocument()` method - `frontend/app/composables/useDocuments.ts` — add `type` to interface + `updateDocument()` method
- `Inventory_frontend/app/components/DocumentUpload.vue` — add type select - `frontend/app/components/DocumentUpload.vue` — add type select
- `Inventory_frontend/app/components/common/DocumentListInline.vue` — add type badge + edit button - `frontend/app/components/common/DocumentListInline.vue` — add type badge + edit button
- `Inventory_frontend/app/composables/useEntityDocuments.ts` — add `updateDocument` delegation - `frontend/app/composables/useEntityDocuments.ts` — add `updateDocument` delegation
- `Inventory_frontend/app/pages/documents.vue` — add type column + edit button - `frontend/app/pages/documents.vue` — add type column + edit button
--- ---
@@ -266,13 +266,13 @@ git commit -m "feat(documents) : accept type on upload + expose in query control
### Task 4: Frontend — Type Constants + Document Interface ### Task 4: Frontend — Type Constants + Document Interface
**Files:** **Files:**
- Create: `Inventory_frontend/app/shared/documentTypes.ts` - Create: `frontend/app/shared/documentTypes.ts`
- Modify: `Inventory_frontend/app/composables/useDocuments.ts:6-27` (Document interface), `useDocuments.ts:205-253` (upload) - Modify: `frontend/app/composables/useDocuments.ts:6-27` (Document interface), `useDocuments.ts:205-253` (upload)
- [ ] **Step 1: Create documentTypes.ts** - [ ] **Step 1: Create documentTypes.ts**
```typescript ```typescript
// Inventory_frontend/app/shared/documentTypes.ts // frontend/app/shared/documentTypes.ts
export const DOCUMENT_TYPES = [ export const DOCUMENT_TYPES = [
{ value: 'documentation', label: 'Documentation' }, { value: 'documentation', label: 'Documentation' },
{ value: 'devis', label: 'Devis' }, { value: 'devis', label: 'Devis' },
@@ -355,12 +355,12 @@ Add `updateDocument` to the return object.
- [ ] **Step 5: Run lint** - [ ] **Step 5: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix` Run: `cd frontend && npm run lint:fix`
- [ ] **Step 6: Commit frontend** - [ ] **Step 6: Commit frontend**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/shared/documentTypes.ts app/composables/useDocuments.ts git add app/shared/documentTypes.ts app/composables/useDocuments.ts
git commit -m "feat(documents) : add document type constants and updateDocument method" git commit -m "feat(documents) : add document type constants and updateDocument method"
``` ```
@@ -370,7 +370,7 @@ git commit -m "feat(documents) : add document type constants and updateDocument
### Task 5: Frontend — DocumentUpload Type Select ### Task 5: Frontend — DocumentUpload Type Select
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/DocumentUpload.vue` - Modify: `frontend/app/components/DocumentUpload.vue`
- [ ] **Step 1: Add type prop and select to DocumentUpload** - [ ] **Step 1: Add type prop and select to DocumentUpload**
@@ -419,12 +419,12 @@ Note: since DocumentUpload uses `<script setup>` without `lang="ts"`, use `@chan
- [ ] **Step 2: Run lint** - [ ] **Step 2: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix` Run: `cd frontend && npm run lint:fix`
- [ ] **Step 3: Commit** - [ ] **Step 3: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/components/DocumentUpload.vue git add app/components/DocumentUpload.vue
git commit -m "feat(documents) : add type select to DocumentUpload component" git commit -m "feat(documents) : add type select to DocumentUpload component"
``` ```
@@ -434,7 +434,7 @@ git commit -m "feat(documents) : add type select to DocumentUpload component"
### Task 6: Frontend — DocumentEditModal ### Task 6: Frontend — DocumentEditModal
**Files:** **Files:**
- Create: `Inventory_frontend/app/components/DocumentEditModal.vue` - Create: `frontend/app/components/DocumentEditModal.vue`
- [ ] **Step 1: Create DocumentEditModal component** - [ ] **Step 1: Create DocumentEditModal component**
@@ -533,12 +533,12 @@ const save = () => {
- [ ] **Step 2: Run lint** - [ ] **Step 2: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix` Run: `cd frontend && npm run lint:fix`
- [ ] **Step 3: Commit** - [ ] **Step 3: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/components/DocumentEditModal.vue git add app/components/DocumentEditModal.vue
git commit -m "feat(documents) : add DocumentEditModal component" git commit -m "feat(documents) : add DocumentEditModal component"
``` ```
@@ -548,8 +548,8 @@ git commit -m "feat(documents) : add DocumentEditModal component"
### Task 7: Frontend — DocumentListInline + Type Badge + Edit Button ### Task 7: Frontend — DocumentListInline + Type Badge + Edit Button
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/common/DocumentListInline.vue` - Modify: `frontend/app/components/common/DocumentListInline.vue`
- Modify: `Inventory_frontend/app/composables/useEntityDocuments.ts` - Modify: `frontend/app/composables/useEntityDocuments.ts`
- [ ] **Step 1: Add type badge and edit button to DocumentListInline** - [ ] **Step 1: Add type badge and edit button to DocumentListInline**
@@ -622,12 +622,12 @@ Add `editDocument` to the return object.
- [ ] **Step 3: Run lint** - [ ] **Step 3: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix` Run: `cd frontend && npm run lint:fix`
- [ ] **Step 4: Commit** - [ ] **Step 4: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/components/common/DocumentListInline.vue app/composables/useEntityDocuments.ts git add app/components/common/DocumentListInline.vue app/composables/useEntityDocuments.ts
git commit -m "feat(documents) : add type badge and edit button to DocumentListInline" git commit -m "feat(documents) : add type badge and edit button to DocumentListInline"
``` ```
@@ -637,11 +637,11 @@ git commit -m "feat(documents) : add type badge and edit button to DocumentListI
### Task 8: Frontend — Wire Edit Modal in Entity Pages ### Task 8: Frontend — Wire Edit Modal in Entity Pages
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/ComponentItem.vue` - Modify: `frontend/app/components/ComponentItem.vue`
- Modify: `Inventory_frontend/app/components/PieceItem.vue` - Modify: `frontend/app/components/PieceItem.vue`
- Modify: `Inventory_frontend/app/pages/pieces/[id]/edit.vue` - Modify: `frontend/app/pages/pieces/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue` - Modify: `frontend/app/pages/component/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue` - Modify: `frontend/app/pages/product/[id]/edit.vue`
- [ ] **Step 1: Wire in ComponentItem and PieceItem** - [ ] **Step 1: Wire in ComponentItem and PieceItem**
@@ -688,12 +688,12 @@ Pass `type: uploadDocType.value` in the upload context when calling `handleFiles
- [ ] **Step 4: Run lint + typecheck** - [ ] **Step 4: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck` Run: `cd frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/components/ app/pages/ git add app/components/ app/pages/
git commit -m "feat(documents) : wire DocumentEditModal and type select in all entity pages" git commit -m "feat(documents) : wire DocumentEditModal and type select in all entity pages"
``` ```
@@ -703,7 +703,7 @@ git commit -m "feat(documents) : wire DocumentEditModal and type select in all e
### Task 9: Frontend — Documents Global Page ### Task 9: Frontend — Documents Global Page
**Files:** **Files:**
- Modify: `Inventory_frontend/app/pages/documents.vue` - Modify: `frontend/app/pages/documents.vue`
- [ ] **Step 1: Add type column to DataTable** - [ ] **Step 1: Add type column to DataTable**
@@ -765,12 +765,12 @@ Pass `typeFilter` to `fetchDocuments` → `loadDocuments` as a new filter param,
- [ ] **Step 4: Run lint + typecheck** - [ ] **Step 4: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck` Run: `cd frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/pages/documents.vue app/composables/useDocuments.ts git add app/pages/documents.vue app/composables/useDocuments.ts
git commit -m "feat(documents) : add type column, filter, and edit to documents page" git commit -m "feat(documents) : add type column, filter, and edit to documents page"
``` ```
@@ -789,7 +789,7 @@ Expected: all tests pass
- [ ] **Step 2: Run full frontend checks** - [ ] **Step 2: Run full frontend checks**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck && npm run build` Run: `cd frontend && npm run lint:fix && npx nuxi typecheck && npm run build`
Expected: 0 errors Expected: 0 errors
- [ ] **Step 3: Manual verification** - [ ] **Step 3: Manual verification**
@@ -804,6 +804,6 @@ Expected: 0 errors
```bash ```bash
cd /home/matthieu/dev_malio/Inventory cd /home/matthieu/dev_malio/Inventory
git add Inventory_frontend git add frontend
git commit -m "chore(submodule) : update frontend pointer (document types feature)" git commit -m "chore(submodule) : update frontend pointer (document types feature)"
``` ```

View File

@@ -19,9 +19,9 @@
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:195-236` | | T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:195-236` |
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:340-405` | | T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:340-405` |
| T4 | Modify | `src/Controller/CustomFieldValueController.php:199-211` | | T4 | Modify | `src/Controller/CustomFieldValueController.php:199-211` |
| T5 | Modify | `Inventory_frontend/app/composables/useComponentEdit.ts:398-405` | | T5 | Modify | `frontend/app/composables/useComponentEdit.ts:398-405` |
| T5 | Modify | `Inventory_frontend/app/composables/usePieceEdit.ts:407-414` | | T5 | Modify | `frontend/app/composables/usePieceEdit.ts:407-414` |
| T6 | Modify | `Inventory_frontend/app/composables/useComponentCreate.ts` (same pattern if present) | | T6 | Modify | `frontend/app/composables/useComponentCreate.ts` (same pattern if present) |
--- ---
@@ -347,8 +347,8 @@ git commit -m "fix(custom-fields) : prevent creation of orphan CustomField witho
Consequence : le `definitionMap` est toujours vide, les champs perso sans `customFieldId` existant ne trouvent pas leur definition et sont envoyes sans `definitionId` (fallback sur metadata = CustomField orphelin cote backend = Task 4). Consequence : le `definitionMap` est toujours vide, les champs perso sans `customFieldId` existant ne trouvent pas leur definition et sont envoyes sans `definitionId` (fallback sur metadata = CustomField orphelin cote backend = Task 4).
**Files:** **Files:**
- Modify: `Inventory_frontend/app/composables/useComponentEdit.ts:401-403` - Modify: `frontend/app/composables/useComponentEdit.ts:401-403`
- Modify: `Inventory_frontend/app/composables/usePieceEdit.ts:410-412` - Modify: `frontend/app/composables/usePieceEdit.ts:410-412`
- [ ] **Step 1: Fix useComponentEdit.ts** - [ ] **Step 1: Fix useComponentEdit.ts**
@@ -387,13 +387,13 @@ Verifier `useComponentCreate.ts`, `pieces/create.vue`, `product/[id]/edit.vue` p
- [ ] **Step 4: Lint + typecheck** - [ ] **Step 4: Lint + typecheck**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck cd frontend && npm run lint:fix && npx nuxi typecheck
``` ```
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/composables/useComponentEdit.ts app/composables/usePieceEdit.ts git add app/composables/useComponentEdit.ts app/composables/usePieceEdit.ts
git commit -m "fix(custom-fields) : use structure.customFields path for definition lookup" git commit -m "fix(custom-fields) : use structure.customFields path for definition lookup"
``` ```

View File

@@ -15,7 +15,7 @@
### Task 1: Multi-select site checkboxes on Parc Machines ### Task 1: Multi-select site checkboxes on Parc Machines
**Files:** **Files:**
- Modify: `Inventory_frontend/app/pages/machines/index.vue` - Modify: `frontend/app/pages/machines/index.vue`
- [ ] **Step 1: Replace `selectedSite` ref with reactive Set** - [ ] **Step 1: Replace `selectedSite` ref with reactive Set**
@@ -90,14 +90,14 @@ Open `http://localhost:3001/machines`. Confirm:
- [ ] **Step 7: Run frontend lint** - [ ] **Step 7: Run frontend lint**
Run: `cd Inventory_frontend && npm run lint:fix` Run: `cd frontend && npm run lint:fix`
--- ---
### Task 2: Alphabetical sorting on Parc Machines ### Task 2: Alphabetical sorting on Parc Machines
**Files:** **Files:**
- Modify: `Inventory_frontend/app/pages/machines/index.vue` - Modify: `frontend/app/pages/machines/index.vue`
- [ ] **Step 1: Add sort to `filteredMachines` computed** - [ ] **Step 1: Add sort to `filteredMachines` computed**
@@ -140,7 +140,7 @@ Open `http://localhost:3001/machines`. Confirm machines are sorted A→Z by name
- [ ] **Step 3: Commit Tasks 1 + 2** - [ ] **Step 3: Commit Tasks 1 + 2**
```bash ```bash
cd Inventory_frontend && git add app/pages/machines/index.vue && git commit -m "feat(machines) : multi-select site checkboxes + alphabetical sort" cd frontend && git add app/pages/machines/index.vue && git commit -m "feat(machines) : multi-select site checkboxes + alphabetical sort"
``` ```
--- ---
@@ -342,9 +342,9 @@ git add src/Doctrine/SearchByNameOrReferenceExtension.php tests/Api/FilterTest.p
### Task 4: Frontend — Switch composables from `name` to `q` ### Task 4: Frontend — Switch composables from `name` to `q`
**Files:** **Files:**
- Modify: `Inventory_frontend/app/composables/usePieces.ts` - Modify: `frontend/app/composables/usePieces.ts`
- Modify: `Inventory_frontend/app/composables/useComposants.ts` - Modify: `frontend/app/composables/useComposants.ts`
- Modify: `Inventory_frontend/app/composables/useProducts.ts` - Modify: `frontend/app/composables/useProducts.ts`
- [ ] **Step 1: Update `usePieces.ts`** - [ ] **Step 1: Update `usePieces.ts`**
@@ -385,7 +385,7 @@ params.set('q', search.trim())
- [ ] **Step 4: Run frontend lint** - [ ] **Step 4: Run frontend lint**
Run: `cd Inventory_frontend && npm run lint:fix` Run: `cd frontend && npm run lint:fix`
- [ ] **Step 5: Verify in browser** - [ ] **Step 5: Verify in browser**
@@ -399,11 +399,11 @@ Confirm that searching by a reference value returns the correct results.
- [ ] **Step 6: Commit frontend changes** - [ ] **Step 6: Commit frontend changes**
```bash ```bash
cd Inventory_frontend && git add app/composables/usePieces.ts app/composables/useComposants.ts app/composables/useProducts.ts && git commit -m "feat(search) : use q param for OR search on name/reference" cd frontend && git add app/composables/usePieces.ts app/composables/useComposants.ts app/composables/useProducts.ts && git commit -m "feat(search) : use q param for OR search on name/reference"
``` ```
- [ ] **Step 7: Update submodule pointer in main repo** - [ ] **Step 7: Update submodule pointer in main repo**
```bash ```bash
cd /home/matthieu/dev_malio/Inventory && git add Inventory_frontend && git commit -m "chore(submodule) : update frontend pointer (OR search + site checkboxes)" cd /home/matthieu/dev_malio/Inventory && git add frontend && git commit -m "chore(submodule) : update frontend pointer (OR search + site checkboxes)"
``` ```

View File

@@ -1100,13 +1100,13 @@ git commit -m "feat(detail) : update catalogs and cross-links to use detail page
- [ ] **Step 1: Run lint** - [ ] **Step 1: Run lint**
```bash ```bash
cd Inventory_frontend && npm run lint:fix cd frontend && npm run lint:fix
``` ```
- [ ] **Step 2: Run typecheck** - [ ] **Step 2: Run typecheck**
```bash ```bash
cd Inventory_frontend && npx nuxi typecheck cd frontend && npx nuxi typecheck
``` ```
Fix any errors found. Fix any errors found.

View File

@@ -29,15 +29,15 @@
- `src/Repository/AuditLogRepository.php` — Add `findVersionHistory()` method - `src/Repository/AuditLogRepository.php` — Add `findVersionHistory()` method
### Frontend — New Files ### Frontend — New Files
- `Inventory_frontend/app/composables/useEntityVersions.ts` — API calls for versions/preview/restore - `frontend/app/composables/useEntityVersions.ts` — API calls for versions/preview/restore
- `Inventory_frontend/app/components/common/EntityVersionList.vue` — Version list with restore button - `frontend/app/components/common/EntityVersionList.vue` — Version list with restore button
- `Inventory_frontend/app/components/common/VersionRestoreModal.vue` — Preview + confirm modal - `frontend/app/components/common/VersionRestoreModal.vue` — Preview + confirm modal
### Frontend — Modified Files ### Frontend — Modified Files
- `Inventory_frontend/app/pages/machine/[id].vue` — Add Versions section - `frontend/app/pages/machine/[id].vue` — Add Versions section
- `Inventory_frontend/app/pages/component/[id]/edit.vue` — Add Versions section - `frontend/app/pages/component/[id]/edit.vue` — Add Versions section
- `Inventory_frontend/app/pages/piece/[id].vue` — Add Versions section - `frontend/app/pages/piece/[id].vue` — Add Versions section
- `Inventory_frontend/app/pages/product/[id]/edit.vue` — Add Versions section - `frontend/app/pages/product/[id]/edit.vue` — Add Versions section
--- ---
@@ -1870,7 +1870,7 @@ git commit -m "test(versioning) : add EntityVersionTest for list, preview and re
## Task 9b: Frontend — add `restore` action label to historyDisplayUtils ## Task 9b: Frontend — add `restore` action label to historyDisplayUtils
**Files:** **Files:**
- Modify: `Inventory_frontend/app/shared/utils/historyDisplayUtils.ts` - Modify: `frontend/app/shared/utils/historyDisplayUtils.ts`
- [ ] **Step 1: Add `restore` case to `historyActionLabel`** - [ ] **Step 1: Add `restore` case to `historyActionLabel`**
@@ -1888,7 +1888,7 @@ export const historyActionLabel = (action: string): string => {
- [ ] **Step 2: Commit in frontend repo** - [ ] **Step 2: Commit in frontend repo**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/shared/utils/historyDisplayUtils.ts git add app/shared/utils/historyDisplayUtils.ts
git commit -m "feat(versioning) : add restore action label to historyDisplayUtils" git commit -m "feat(versioning) : add restore action label to historyDisplayUtils"
cd .. cd ..
@@ -1899,7 +1899,7 @@ cd ..
## Task 10: Frontend — useEntityVersions composable ## Task 10: Frontend — useEntityVersions composable
**Files:** **Files:**
- Create: `Inventory_frontend/app/composables/useEntityVersions.ts` - Create: `frontend/app/composables/useEntityVersions.ts`
- [ ] **Step 1: Create the composable** - [ ] **Step 1: Create the composable**
@@ -2004,16 +2004,16 @@ export function useEntityVersions(deps: Deps) {
- [ ] **Step 2: Run lint** - [ ] **Step 2: Run lint**
Run (in `Inventory_frontend/`): `npm run lint:fix` Run (in `frontend/`): `npm run lint:fix`
- [ ] **Step 3: Run typecheck** - [ ] **Step 3: Run typecheck**
Run (in `Inventory_frontend/`): `npx nuxi typecheck` Run (in `frontend/`): `npx nuxi typecheck`
- [ ] **Step 4: Commit in frontend repo** - [ ] **Step 4: Commit in frontend repo**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/composables/useEntityVersions.ts git add app/composables/useEntityVersions.ts
git commit -m "feat(versioning) : add useEntityVersions composable" git commit -m "feat(versioning) : add useEntityVersions composable"
cd .. cd ..
@@ -2024,7 +2024,7 @@ cd ..
## Task 11: Frontend — VersionRestoreModal component ## Task 11: Frontend — VersionRestoreModal component
**Files:** **Files:**
- Create: `Inventory_frontend/app/components/common/VersionRestoreModal.vue` - Create: `frontend/app/components/common/VersionRestoreModal.vue`
- [ ] **Step 1: Create the modal component** - [ ] **Step 1: Create the modal component**
@@ -2129,12 +2129,12 @@ const formatValue = (value: unknown): string => {
- [ ] **Step 2: Run lint** - [ ] **Step 2: Run lint**
Run (in `Inventory_frontend/`): `npm run lint:fix` Run (in `frontend/`): `npm run lint:fix`
- [ ] **Step 3: Commit** - [ ] **Step 3: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/components/common/VersionRestoreModal.vue git add app/components/common/VersionRestoreModal.vue
git commit -m "feat(versioning) : add VersionRestoreModal component" git commit -m "feat(versioning) : add VersionRestoreModal component"
cd .. cd ..
@@ -2145,7 +2145,7 @@ cd ..
## Task 12: Frontend — EntityVersionList component ## Task 12: Frontend — EntityVersionList component
**Files:** **Files:**
- Create: `Inventory_frontend/app/components/common/EntityVersionList.vue` - Create: `frontend/app/components/common/EntityVersionList.vue`
- [ ] **Step 1: Create the version list component** - [ ] **Step 1: Create the version list component**
@@ -2296,12 +2296,12 @@ onMounted(() => {
- [ ] **Step 2: Run lint** - [ ] **Step 2: Run lint**
Run (in `Inventory_frontend/`): `npm run lint:fix` Run (in `frontend/`): `npm run lint:fix`
- [ ] **Step 3: Commit** - [ ] **Step 3: Commit**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/components/common/EntityVersionList.vue git add app/components/common/EntityVersionList.vue
git commit -m "feat(versioning) : add EntityVersionList component with restore flow" git commit -m "feat(versioning) : add EntityVersionList component with restore flow"
cd .. cd ..
@@ -2312,10 +2312,10 @@ cd ..
## Task 13: Frontend — Integrate EntityVersionList into detail pages ## Task 13: Frontend — Integrate EntityVersionList into detail pages
**Files:** **Files:**
- Modify: `Inventory_frontend/app/pages/machine/[id].vue` - Modify: `frontend/app/pages/machine/[id].vue`
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue` - Modify: `frontend/app/pages/component/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/piece/[id].vue` - Modify: `frontend/app/pages/piece/[id].vue`
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue` - Modify: `frontend/app/pages/product/[id]/edit.vue`
- [ ] **Step 1: Add EntityVersionList to machine/[id].vue** - [ ] **Step 1: Add EntityVersionList to machine/[id].vue**
@@ -2398,16 +2398,16 @@ import EntityVersionList from '~/components/common/EntityVersionList.vue'
- [ ] **Step 5: Run lint** - [ ] **Step 5: Run lint**
Run (in `Inventory_frontend/`): `npm run lint:fix` Run (in `frontend/`): `npm run lint:fix`
- [ ] **Step 6: Run typecheck** - [ ] **Step 6: Run typecheck**
Run (in `Inventory_frontend/`): `npx nuxi typecheck` Run (in `frontend/`): `npx nuxi typecheck`
- [ ] **Step 7: Commit in frontend repo** - [ ] **Step 7: Commit in frontend repo**
```bash ```bash
cd Inventory_frontend cd frontend
git add app/pages/machine/\[id\].vue app/pages/component/\[id\]/edit.vue app/pages/piece/\[id\].vue app/pages/product/\[id\]/edit.vue git add app/pages/machine/\[id\].vue app/pages/component/\[id\]/edit.vue app/pages/piece/\[id\].vue app/pages/product/\[id\]/edit.vue
git commit -m "feat(versioning) : integrate EntityVersionList into all detail pages" git commit -m "feat(versioning) : integrate EntityVersionList into all detail pages"
cd .. cd ..
@@ -2416,7 +2416,7 @@ cd ..
- [ ] **Step 8: Update submodule pointer in main repo** - [ ] **Step 8: Update submodule pointer in main repo**
```bash ```bash
git add Inventory_frontend git add frontend
git commit -m "chore(submodule) : update frontend pointer (entity versioning)" git commit -m "chore(submodule) : update frontend pointer (entity versioning)"
``` ```
@@ -2431,7 +2431,7 @@ Expected: All tests pass.
- [ ] **Step 2: Run frontend lint + typecheck** - [ ] **Step 2: Run frontend lint + typecheck**
Run (in `Inventory_frontend/`): `npm run lint:fix && npx nuxi typecheck` Run (in `frontend/`): `npm run lint:fix && npx nuxi typecheck`
Expected: 0 errors. Expected: 0 errors.
- [ ] **Step 3: Manual smoke test** - [ ] **Step 3: Manual smoke test**

View File

@@ -14,12 +14,12 @@
| Action | File | Responsibility | | Action | File | Responsibility |
|--------|------|----------------| |--------|------|----------------|
| Modify | `Inventory_frontend/app/components/machine/MachineInfoCard.vue` | Remove blur-triggered saves, expose saveFieldDefinitions via defineExpose | | Modify | `frontend/app/components/machine/MachineInfoCard.vue` | Remove blur-triggered saves, expose saveFieldDefinitions via defineExpose |
| Modify | `Inventory_frontend/app/components/machine/MachineCustomFieldDefEditor.vue` | Remove standalone save button | | Modify | `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` | Remove standalone save button |
| Modify | `Inventory_frontend/app/pages/machine/[id].vue` | Add Save/Cancel buttons, wire submitEdition via template ref | | Modify | `frontend/app/pages/machine/[id].vue` | Add Save/Cancel buttons, wire submitEdition via template ref |
| Modify | `Inventory_frontend/app/composables/useMachineDetailData.ts` | Add submitEdition, cancelEdition, saving, canSubmit | | Modify | `frontend/app/composables/useMachineDetailData.ts` | Add submitEdition, cancelEdition, saving, canSubmit |
| Modify | `Inventory_frontend/app/composables/useMachineDetailUpdates.ts` | Remove auto-save from handleMachineConstructeurChange | | Modify | `frontend/app/composables/useMachineDetailUpdates.ts` | Remove auto-save from handleMachineConstructeurChange |
| Modify | `Inventory_frontend/app/composables/useMachineDetailCustomFields.ts` | Add saveAllMachineCustomFields batch method | | Modify | `frontend/app/composables/useMachineDetailCustomFields.ts` | Add saveAllMachineCustomFields batch method |
| Modify | `src/EventSubscriber/MachineAuditSubscriber.php` | Enrich snapshot with links + detect link changes in onFlush | | Modify | `src/EventSubscriber/MachineAuditSubscriber.php` | Enrich snapshot with links + detect link changes in onFlush |
--- ---
@@ -27,7 +27,7 @@
### Task 1: Remove blur-triggered saves from MachineInfoCard ### Task 1: Remove blur-triggered saves from MachineInfoCard
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/machine/MachineInfoCard.vue` - Modify: `frontend/app/components/machine/MachineInfoCard.vue`
- [ ] **Step 1: Remove `@blur` from name input (line 17)** - [ ] **Step 1: Remove `@blur` from name input (line 17)**
@@ -143,7 +143,7 @@ defineExpose({
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
```bash ```bash
git add Inventory_frontend/app/components/machine/MachineInfoCard.vue git add frontend/app/components/machine/MachineInfoCard.vue
git commit -m "refactor(machine) : remove blur-triggered auto-saves from MachineInfoCard" git commit -m "refactor(machine) : remove blur-triggered auto-saves from MachineInfoCard"
``` ```
@@ -152,7 +152,7 @@ git commit -m "refactor(machine) : remove blur-triggered auto-saves from Machine
### Task 2: Remove standalone save button from MachineCustomFieldDefEditor ### Task 2: Remove standalone save button from MachineCustomFieldDefEditor
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/machine/MachineCustomFieldDefEditor.vue` - Modify: `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
- [ ] **Step 1: Remove the "Enregistrer les champs" button (lines 7-15)** - [ ] **Step 1: Remove the "Enregistrer les champs" button (lines 7-15)**
@@ -201,7 +201,7 @@ defineEmits<{
- [ ] **Step 3: Commit** - [ ] **Step 3: Commit**
```bash ```bash
git add Inventory_frontend/app/components/machine/MachineCustomFieldDefEditor.vue git add frontend/app/components/machine/MachineCustomFieldDefEditor.vue
git commit -m "refactor(machine) : remove standalone save button from custom field def editor" git commit -m "refactor(machine) : remove standalone save button from custom field def editor"
``` ```
@@ -210,7 +210,7 @@ git commit -m "refactor(machine) : remove standalone save button from custom fie
### Task 3: Stop auto-save in handleMachineConstructeurChange ### Task 3: Stop auto-save in handleMachineConstructeurChange
**Files:** **Files:**
- Modify: `Inventory_frontend/app/composables/useMachineDetailUpdates.ts:211-214` - Modify: `frontend/app/composables/useMachineDetailUpdates.ts:211-214`
- [ ] **Step 1: Remove the auto-save call** - [ ] **Step 1: Remove the auto-save call**
@@ -231,7 +231,7 @@ With:
- [ ] **Step 2: Commit** - [ ] **Step 2: Commit**
```bash ```bash
git add Inventory_frontend/app/composables/useMachineDetailUpdates.ts git add frontend/app/composables/useMachineDetailUpdates.ts
git commit -m "refactor(machine) : stop auto-saving on constructeur change" git commit -m "refactor(machine) : stop auto-saving on constructeur change"
``` ```
@@ -240,7 +240,7 @@ git commit -m "refactor(machine) : stop auto-saving on constructeur change"
### Task 4: Add batch custom field save method ### Task 4: Add batch custom field save method
**Files:** **Files:**
- Modify: `Inventory_frontend/app/composables/useMachineDetailCustomFields.ts` - Modify: `frontend/app/composables/useMachineDetailCustomFields.ts`
- [ ] **Step 1: Add `saveAllMachineCustomFields` method** - [ ] **Step 1: Add `saveAllMachineCustomFields` method**
@@ -325,7 +325,7 @@ With:
- [ ] **Step 3: Commit** - [ ] **Step 3: Commit**
```bash ```bash
git add Inventory_frontend/app/composables/useMachineDetailCustomFields.ts git add frontend/app/composables/useMachineDetailCustomFields.ts
git commit -m "feat(machine) : add batch saveAllMachineCustomFields method" git commit -m "feat(machine) : add batch saveAllMachineCustomFields method"
``` ```
@@ -334,7 +334,7 @@ git commit -m "feat(machine) : add batch saveAllMachineCustomFields method"
### Task 5: Add submitEdition, cancelEdition, saving, canSubmit to orchestrator ### Task 5: Add submitEdition, cancelEdition, saving, canSubmit to orchestrator
**Files:** **Files:**
- Modify: `Inventory_frontend/app/composables/useMachineDetailData.ts` - Modify: `frontend/app/composables/useMachineDetailData.ts`
- [ ] **Step 1: Add `saving` ref in the core state block (after line 63)** - [ ] **Step 1: Add `saving` ref in the core state block (after line 63)**
@@ -423,7 +423,7 @@ Add to the return object:
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
```bash ```bash
git add Inventory_frontend/app/composables/useMachineDetailData.ts git add frontend/app/composables/useMachineDetailData.ts
git commit -m "feat(machine) : add submitEdition, cancelEdition, saving, canSubmit" git commit -m "feat(machine) : add submitEdition, cancelEdition, saving, canSubmit"
``` ```
@@ -432,7 +432,7 @@ git commit -m "feat(machine) : add submitEdition, cancelEdition, saving, canSubm
### Task 6: Wire Save/Cancel buttons in the page ### Task 6: Wire Save/Cancel buttons in the page
**Files:** **Files:**
- Modify: `Inventory_frontend/app/pages/machine/[id].vue` - Modify: `frontend/app/pages/machine/[id].vue`
- [ ] **Step 1: Add template ref on MachineInfoCard (line 56)** - [ ] **Step 1: Add template ref on MachineInfoCard (line 56)**
@@ -597,7 +597,7 @@ const historyFieldLabels = {
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
```bash ```bash
git add Inventory_frontend/app/pages/machine/[id].vue git add frontend/app/pages/machine/[id].vue
git commit -m "feat(machine) : add single save button and wire cancel/submit" git commit -m "feat(machine) : add single save button and wire cancel/submit"
``` ```
@@ -988,13 +988,13 @@ git commit -m "feat(versioning) : detect machine link add/remove in onFlush and
- [ ] **Step 1: Run ESLint fix** - [ ] **Step 1: Run ESLint fix**
```bash ```bash
cd Inventory_frontend && npm run lint:fix cd frontend && npm run lint:fix
``` ```
- [ ] **Step 2: Run typecheck** - [ ] **Step 2: Run typecheck**
```bash ```bash
cd Inventory_frontend && npx nuxi typecheck cd frontend && npx nuxi typecheck
``` ```
Expected: 0 errors. Expected: 0 errors.
@@ -1039,7 +1039,7 @@ git add -A && git commit -m "fix(machine) : fix cs-fixer and test issues from si
```bash ```bash
make start make start
cd Inventory_frontend && npm run dev cd frontend && npm run dev
``` ```
- [ ] **Step 2: Test single save flow** - [ ] **Step 2: Test single save flow**

View File

@@ -94,8 +94,8 @@ git commit --no-verify -m "feat(constructeur) : add SearchFilter on Constructeur
### Task F2: Frontend — Add types + useConstructeurLinks composable ### Task F2: Frontend — Add types + useConstructeurLinks composable
**Files:** **Files:**
- Modify: `Inventory_frontend/app/shared/constructeurUtils.ts` - Modify: `frontend/app/shared/constructeurUtils.ts`
- Create: `Inventory_frontend/app/composables/useConstructeurLinks.ts` - Create: `frontend/app/composables/useConstructeurLinks.ts`
- [ ] **Step 1: Add ConstructeurLinkEntry type to constructeurUtils.ts** - [ ] **Step 1: Add ConstructeurLinkEntry type to constructeurUtils.ts**
@@ -227,7 +227,7 @@ export function useConstructeurLinks() {
- [ ] **Step 3: Commit** - [ ] **Step 3: Commit**
```bash ```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinkEntry type and useConstructeurLinks composable" cd frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinkEntry type and useConstructeurLinks composable"
``` ```
--- ---
@@ -235,7 +235,7 @@ cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add C
### Task F3: Frontend — Create ConstructeurLinksTable component ### Task F3: Frontend — Create ConstructeurLinksTable component
**Files:** **Files:**
- Create: `Inventory_frontend/app/components/ConstructeurLinksTable.vue` - Create: `frontend/app/components/ConstructeurLinksTable.vue`
- [ ] **Step 1: Create the component** - [ ] **Step 1: Create the component**
@@ -338,7 +338,7 @@ const removeLink = (index: number) => {
- [ ] **Step 2: Commit** - [ ] **Step 2: Commit**
```bash ```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinksTable component" cd frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinksTable component"
``` ```
--- ---
@@ -346,9 +346,9 @@ cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add C
### Task F4: Frontend — Update piece edit flow (model case) ### Task F4: Frontend — Update piece edit flow (model case)
**Files:** **Files:**
- Modify: `Inventory_frontend/app/composables/usePieceEdit.ts` - Modify: `frontend/app/composables/usePieceEdit.ts`
- Modify: `Inventory_frontend/app/pages/piece/[id].vue` - Modify: `frontend/app/pages/piece/[id].vue`
- Modify: `Inventory_frontend/app/composables/usePieces.ts` - Modify: `frontend/app/composables/usePieces.ts`
This task establishes the pattern for all entity types. This task establishes the pattern for all entity types.
@@ -376,13 +376,13 @@ In `createPiece()` and `updatePieceData()`: stop wrapping payload with `buildCon
- [ ] **Step 4: Lint and typecheck** - [ ] **Step 4: Lint and typecheck**
```bash ```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck cd frontend && npm run lint:fix && npx nuxi typecheck
``` ```
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : update piece edit flow with supplier references" cd frontend && git add -A && git commit -m "feat(constructeur) : update piece edit flow with supplier references"
``` ```
--- ---
@@ -392,11 +392,11 @@ cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : updat
Same pattern as Task F4 but for composants. Same pattern as Task F4 but for composants.
**Files:** **Files:**
- Modify: `Inventory_frontend/app/composables/useComponentEdit.ts` - Modify: `frontend/app/composables/useComponentEdit.ts`
- Modify: `Inventory_frontend/app/pages/component/[id]/index.vue` - Modify: `frontend/app/pages/component/[id]/index.vue`
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue` - Modify: `frontend/app/pages/component/[id]/edit.vue`
- Modify: `Inventory_frontend/app/composables/useComposants.ts` - Modify: `frontend/app/composables/useComposants.ts`
- Modify: `Inventory_frontend/app/pages/component/create.vue` - Modify: `frontend/app/pages/component/create.vue`
--- ---
@@ -406,10 +406,10 @@ Same pattern as Task F4 but for products.
**Files:** **Files:**
- Modify: product edit composable (if exists) or inline pages - Modify: product edit composable (if exists) or inline pages
- Modify: `Inventory_frontend/app/pages/product/[id]/index.vue` - Modify: `frontend/app/pages/product/[id]/index.vue`
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue` - Modify: `frontend/app/pages/product/[id]/edit.vue`
- Modify: `Inventory_frontend/app/composables/useProducts.ts` - Modify: `frontend/app/composables/useProducts.ts`
- Modify: `Inventory_frontend/app/pages/product/create.vue` - Modify: `frontend/app/pages/product/create.vue`
--- ---
@@ -418,11 +418,11 @@ Same pattern as Task F4 but for products.
Machine uses a different architecture (MachineStructureController, useMachineDetailData/Updates). Machine uses a different architecture (MachineStructureController, useMachineDetailData/Updates).
**Files:** **Files:**
- Modify: `Inventory_frontend/app/composables/useMachineDetailData.ts` - Modify: `frontend/app/composables/useMachineDetailData.ts`
- Modify: `Inventory_frontend/app/composables/useMachineDetailUpdates.ts` - Modify: `frontend/app/composables/useMachineDetailUpdates.ts`
- Modify: `Inventory_frontend/app/pages/machine/[id].vue` - Modify: `frontend/app/pages/machine/[id].vue`
- Modify: `Inventory_frontend/app/components/machine/MachineInfoCard.vue` - Modify: `frontend/app/components/machine/MachineInfoCard.vue`
- Modify: `Inventory_frontend/app/composables/useMachines.ts` - Modify: `frontend/app/composables/useMachines.ts`
Key differences: Key differences:
- Machine data comes from `/api/machines/{id}/structure` (custom controller) which already returns the new constructeur link format - Machine data comes from `/api/machines/{id}/structure` (custom controller) which already returns the new constructeur link format
@@ -434,8 +434,8 @@ Key differences:
### Task F8: Frontend — Update machine structure components (PieceItem, ComponentItem) ### Task F8: Frontend — Update machine structure components (PieceItem, ComponentItem)
**Files:** **Files:**
- Modify: `Inventory_frontend/app/components/PieceItem.vue` - Modify: `frontend/app/components/PieceItem.vue`
- Modify: `Inventory_frontend/app/components/ComponentItem.vue` - Modify: `frontend/app/components/ComponentItem.vue`
These components display constructeurs in the machine structure tree and handle inline editing. Update them to: These components display constructeurs in the machine structure tree and handle inline editing. Update them to:
- Read from `constructeurLinks` format in the machine structure response - Read from `constructeurLinks` format in the machine structure response
@@ -447,9 +447,9 @@ These components display constructeurs in the machine structure tree and handle
### Task F9: Frontend — Update create pages ### Task F9: Frontend — Update create pages
**Files:** **Files:**
- Modify: `Inventory_frontend/app/pages/pieces/create.vue` - Modify: `frontend/app/pages/pieces/create.vue`
- Modify: `Inventory_frontend/app/pages/component/create.vue` - Modify: `frontend/app/pages/component/create.vue`
- Modify: `Inventory_frontend/app/pages/product/create.vue` - Modify: `frontend/app/pages/product/create.vue`
On creation pages, there are no existing links. The flow is: On creation pages, there are no existing links. The flow is:
1. User selects constructeurs + optionally fills supplierReference 1. User selects constructeurs + optionally fills supplierReference

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,841 @@
# Machine Context Custom Fields — Backend Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Parallel plan:** This is the backend half. The frontend plan is at `2026-04-02-machine-context-fields-frontend.md`. Both can run in parallel on separate worktrees — they share no files.
**Goal:** Add `machineContextOnly` flag on `CustomField`, link FKs on `CustomFieldValue`, structure controller normalization, upsert support, clone support, and tests.
**Architecture:** `CustomField.machineContextOnly` flags definitions. `CustomFieldValue` gets nullable FKs to `MachineComponentLink`/`MachinePieceLink`. The structure response returns `contextCustomFields` and `contextCustomFieldValues` per link. Upsert and clone are extended.
**Tech Stack:** Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, PHPUnit 12
**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md`
---
## File Map
### Create
- `migrations/VersionXXXX_MachineContextCustomFields.php` — migration
- `tests/Api/Entity/MachineContextCustomFieldTest.php` — test class
### Modify
- `src/Entity/CustomField.php` — add `machineContextOnly` property
- `src/Entity/CustomFieldValue.php` — add `machineComponentLink` and `machinePieceLink` FKs
- `src/Entity/MachineComponentLink.php` — add `contextFieldValues` collection
- `src/Entity/MachinePieceLink.php` — add `contextFieldValues` collection
- `src/Controller/MachineStructureController.php` — normalize context fields + clone
- `src/Controller/CustomFieldValueController.php` — link-based upsert
- `tests/AbstractApiTestCase.php` — extend factories
---
## Task 1: Migration + Entity `CustomField` — `machineContextOnly`
**Files:**
- Modify: `src/Entity/CustomField.php` (add property after line 56)
- [ ] **Step 1: Add `machineContextOnly` property to `CustomField` entity**
In `src/Entity/CustomField.php`, add after the `$required` property (line 56):
```php
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private bool $machineContextOnly = false;
```
Add getter/setter before the closing `}`:
```php
public function isMachineContextOnly(): bool
{
return $this->machineContextOnly;
}
public function setMachineContextOnly(bool $machineContextOnly): static
{
$this->machineContextOnly = $machineContextOnly;
return $this;
}
```
- [ ] **Step 2: Generate and adjust migration**
```bash
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
```
Edit the generated migration to use idempotent SQL:
```sql
ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL;
```
- [ ] **Step 3: Run migration**
```bash
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 4: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 5: Commit**
```bash
git add src/Entity/CustomField.php migrations/
git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity"
```
---
## Task 2: Entity `CustomFieldValue` — link FKs + inverse collections
**Files:**
- Modify: `src/Entity/CustomFieldValue.php` (add after line 67 — `$product` property)
- Modify: `src/Entity/MachineComponentLink.php` (add after line 72 — `$productLinks`)
- Modify: `src/Entity/MachinePieceLink.php` (add after line 61 — `$productLinks`)
- [ ] **Step 1: Add FKs to `CustomFieldValue`**
In `src/Entity/CustomFieldValue.php`, add after the `$product` property (line 67):
```php
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineComponentLink $machineComponentLink = null;
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachinePieceLink $machinePieceLink = null;
```
Add getters/setters before the closing `}`:
```php
public function getMachineComponentLink(): ?MachineComponentLink
{
return $this->machineComponentLink;
}
public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static
{
$this->machineComponentLink = $machineComponentLink;
return $this;
}
public function getMachinePieceLink(): ?MachinePieceLink
{
return $this->machinePieceLink;
}
public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static
{
$this->machinePieceLink = $machinePieceLink;
return $this;
}
```
- [ ] **Step 2: Add `contextFieldValues` collection to `MachineComponentLink`**
In `src/Entity/MachineComponentLink.php`, add after the `$productLinks` collection (line 72):
```php
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
```
In the constructor (line 95), add:
```php
$this->contextFieldValues = new ArrayCollection();
```
Add getter before the closing `}`:
```php
/**
* @return Collection<int, CustomFieldValue>
*/
public function getContextFieldValues(): Collection
{
return $this->contextFieldValues;
}
```
- [ ] **Step 3: Add `contextFieldValues` collection to `MachinePieceLink`**
In `src/Entity/MachinePieceLink.php`, add after the `$productLinks` collection (line 61):
```php
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
```
In the constructor (line 86), add:
```php
$this->contextFieldValues = new ArrayCollection();
```
Add getter:
```php
/**
* @return Collection<int, CustomFieldValue>
*/
public function getContextFieldValues(): Collection
{
return $this->contextFieldValues;
}
```
- [ ] **Step 4: Generate and adjust migration**
```bash
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
```
Edit migration to use idempotent SQL:
```sql
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL;
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link
FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link
FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid);
CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid);
```
- [ ] **Step 5: Run migration + linter**
```bash
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
make php-cs-fixer-allow-risky
```
- [ ] **Step 6: Commit**
```bash
git add src/Entity/CustomFieldValue.php src/Entity/MachineComponentLink.php src/Entity/MachinePieceLink.php migrations/
git commit -m "feat(custom-fields) : add link FKs to CustomFieldValue for machine context"
```
---
## Task 3: Test factories — extend helpers
**Files:**
- Modify: `tests/AbstractApiTestCase.php:399-461`
- [ ] **Step 1: Update `createCustomField()` factory**
In `tests/AbstractApiTestCase.php`, add `bool $machineContextOnly = false` parameter at the end of `createCustomField` (line 399), and add `$cf->setMachineContextOnly($machineContextOnly);` after `$cf->setOrderIndex($orderIndex);` (line 411).
- [ ] **Step 2: Update `createCustomFieldValue()` factory**
Add two new nullable parameters at the end of `createCustomFieldValue` (line 432):
```php
?MachineComponentLink $machineComponentLink = null,
?MachinePieceLink $machinePieceLink = null,
```
Add the corresponding setter calls **after the closing `}` of the `if (null !== $product)` block** (after line 454, NOT inside it):
```php
if (null !== $machineComponentLink) {
$cfv->setMachineComponentLink($machineComponentLink);
}
if (null !== $machinePieceLink) {
$cfv->setMachinePieceLink($machinePieceLink);
}
```
- [ ] **Step 3: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 4: Commit**
```bash
git add tests/AbstractApiTestCase.php
git commit -m "test(custom-fields) : extend factories for machineContextOnly and link params"
```
---
## Task 4: `MachineStructureController` — normalize context fields
**Files:**
- Modify: `src/Controller/MachineStructureController.php`
- [ ] **Step 1: Add `machineContextOnly` to all normalization methods**
In `normalizeCustomFields` (line 601), add to the output array at line 615:
```php
'machineContextOnly' => $customField->isMachineContextOnly(),
```
In `normalizeCustomFieldDefinitions` (line 838), add to the output array at line 852:
```php
'machineContextOnly' => $cf->isMachineContextOnly(),
```
In `normalizeCustomFieldValues` (line 861), add to the nested `customField` array at line 879:
```php
'machineContextOnly' => $cf->isMachineContextOnly(),
```
- [ ] **Step 2: Add `normalizeContextCustomFieldDefinitions` helper**
Add a new private method after `normalizeCustomFieldValues`:
```php
private function normalizeContextCustomFieldDefinitions(Collection $customFields): array
{
$items = [];
foreach ($customFields as $cf) {
if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) {
continue;
}
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
'machineContextOnly' => true,
];
}
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
return $items;
}
```
- [ ] **Step 3: Update `normalizeComponentLinks` to include context fields**
In `normalizeComponentLinks` (line 622), add `$type` variable and context field keys to the returned array:
```php
private function normalizeComponentLinks(array $links): array
{
return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant();
$parentLink = $link->getParentLink();
$type = $composant->getTypeComposant();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [],
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
];
}, $links);
}
```
- [ ] **Step 4: Update `normalizePieceLinks` to include context fields**
In `normalizePieceLinks` (line 644):
```php
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$parentLink = $link->getParentLink();
$type = $piece->getTypePiece();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'quantity' => $this->resolvePieceQuantity($link),
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [],
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
];
}, $links);
}
```
- [ ] **Step 5: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 6: Commit**
```bash
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : expose context custom fields in machine structure response"
```
---
## Task 5: `CustomFieldValueController` — support link-based upsert
**Files:**
- Modify: `src/Controller/CustomFieldValueController.php`
- [ ] **Step 1: Inject link repositories in constructor**
In the constructor (line 24), add:
```php
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
```
Add use statements at the top:
```php
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
```
- [ ] **Step 2: Extend `resolveTarget` to support link entities**
In `resolveTarget` (line 211), the method applies `strtolower()` on entityType at line 213. The candidate loop and match block must handle this.
Update the candidate list in the foreach (line 217):
```php
foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) {
```
**IMPORTANT:** The candidate loop assigns `$entityType` in camelCase, but `strtolower()` (line 213) only applies when `entityType` comes from the payload directly. Add a normalization after the loop closes (after the existing `break;` at line 226), before the empty-check at line 229:
```php
$entityType = strtolower($entityType);
```
This ensures both code paths (direct payload `entityType` and candidate loop) deliver lowercase to the match block.
Update the match block (line 233) — all keys lowercase, but `resolveEntity` returns camelCase type for `applyTarget`:
```php
return match ($entityType) {
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository),
'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository),
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
};
```
- [ ] **Step 3: Extend `applyTarget` for link entities**
Add two new cases in `applyTarget` (line 252):
```php
case 'machineComponentLink':
$value->setMachineComponentLink($entity);
$value->setComposant($entity->getComposant());
break;
case 'machinePieceLink':
$value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece());
break;
```
- [ ] **Step 4: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 5: Commit**
```bash
git add src/Controller/CustomFieldValueController.php
git commit -m "feat(custom-fields) : support link-based upsert in CustomFieldValueController"
```
---
## Task 6: Clone support — copy context field values
**Files:**
- Modify: `src/Controller/MachineStructureController.php` (after `cloneProductLinks`, before `flush` in `cloneMachine`)
- [ ] **Step 1: Add `cloneContextFieldValues` helper method**
Add after `cloneProductLinks`:
```php
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $pieceLinkMap
*/
private function cloneContextFieldValues(
array $componentLinkMap,
array $pieceLinkMap,
): void {
foreach ($componentLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machineComponentLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachineComponentLink($newLink);
$newValue->setComposant($newLink->getComposant());
$this->entityManager->persist($newValue);
}
}
foreach ($pieceLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machinePieceLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachinePieceLink($newLink);
$newValue->setPiece($newLink->getPiece());
$this->entityManager->persist($newValue);
}
}
}
```
- [ ] **Step 2: Call from `cloneMachine` method**
In `cloneMachine` (line 113), after `$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);` (line 163) and before `$this->entityManager->flush();` (line 165), add:
```php
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
```
- [ ] **Step 3: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 4: Commit**
```bash
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : clone context field values on machine clone"
```
---
## Task 7: Backend tests
**Files:**
- Create: `tests/Api/Entity/MachineContextCustomFieldTest.php`
- [ ] **Step 1: Write test class**
```php
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
class MachineContextCustomFieldTest extends AbstractApiTestCase
{
public function testStructureReturnsContextFieldsOnComponentLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site A');
$modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Voltage',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$normalField = $this->createCustomField(
name: 'Serial',
type: 'text',
typeComposant: $modelType,
);
$machine = $this->createMachine('Machine A', $site);
$composant = $this->createComposant('Motor 1', 'MOT-001', $modelType);
$link = $this->createMachineComponentLink($machine, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '220',
composant: $composant,
machineComponentLink: $link,
);
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$componentLink = $data['componentLinks'][0];
$this->assertArrayHasKey('contextCustomFields', $componentLink);
$this->assertCount(1, $componentLink['contextCustomFields']);
$this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']);
$this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']);
$this->assertArrayHasKey('contextCustomFieldValues', $componentLink);
$this->assertCount(1, $componentLink['contextCustomFieldValues']);
$this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']);
$normalFields = array_filter(
$componentLink['composant']['customFields'],
fn (array $f) => $f['name'] === 'Serial',
);
$this->assertCount(1, $normalFields);
}
public function testStructureReturnsContextFieldsOnPieceLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site B');
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
$contextField = $this->createCustomField(
name: 'Wear Level',
type: 'select',
typePiece: $modelType,
machineContextOnly: true,
);
$contextField->setOptions(['Good', 'Fair', 'Replace']);
$this->getEntityManager()->flush();
$machine = $this->createMachine('Machine B', $site);
$piece = $this->createPiece('Bearing 1', 'BRG-001', $modelType);
$link = $this->createMachinePieceLink($machine, $piece);
$this->createCustomFieldValue(
customField: $contextField,
value: 'Fair',
piece: $piece,
machinePieceLink: $link,
);
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
$data = $response->toArray();
$pieceLink = $data['pieceLinks'][0];
$this->assertCount(1, $pieceLink['contextCustomFields']);
$this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']);
$this->assertCount(1, $pieceLink['contextCustomFieldValues']);
$this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']);
}
public function testUpsertContextFieldValueViaComponentLink(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site C');
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Flow Rate',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$machine = $this->createMachine('Machine C', $site);
$composant = $this->createComposant('Pump 1', 'PMP-001', $modelType);
$link = $this->createMachineComponentLink($machine, $composant);
$response = $client->request('POST', '/api/custom-fields/values/upsert', [
'json' => [
'customFieldId' => $contextField->getId(),
'machineComponentLinkId' => $link->getId(),
'value' => '380',
],
]);
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertSame('380', $data['value']);
}
public function testSameComposantDifferentValuesPerMachine(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site D');
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Pressure',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$machineA = $this->createMachine('Machine A', $site);
$machineB = $this->createMachine('Machine B', $site);
$composant = $this->createComposant('Valve 1', 'VLV-001', $modelType);
$linkA = $this->createMachineComponentLink($machineA, $composant);
$linkB = $this->createMachineComponentLink($machineB, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '100',
composant: $composant,
machineComponentLink: $linkA,
);
$this->createCustomFieldValue(
customField: $contextField,
value: '200',
composant: $composant,
machineComponentLink: $linkB,
);
$dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray();
$this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
$dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray();
$this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
}
public function testMachineContextOnlyFieldSerialization(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site E');
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'Calibration Date',
type: 'date',
typeComposant: $modelType,
machineContextOnly: true,
);
$response = $client->request('GET', '/api/custom_fields/'.$contextField->getId());
$this->assertResponseIsSuccessful();
$data = $response->toArray();
$this->assertTrue($data['machineContextOnly']);
}
public function testCloneMachineCopiesContextFieldValues(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site F');
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'RPM Setting',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$source = $this->createMachine('Source Machine', $site);
$composant = $this->createComposant('Motor C', 'MOTC-001', $modelType);
$link = $this->createMachineComponentLink($source, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '3000',
composant: $composant,
machineComponentLink: $link,
);
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
'json' => [
'name' => 'Cloned Machine',
'siteId' => $site->getId(),
],
]);
$this->assertResponseStatusCodeSame(201);
$data = $response->toArray();
$clonedLink = $data['componentLinks'][0] ?? null;
$this->assertNotNull($clonedLink);
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
}
}
```
- [ ] **Step 2: Run tests**
```bash
make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php
```
Expected: All 6 tests pass.
- [ ] **Step 3: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 4: Run full test suite**
```bash
make test
```
Expected: All existing tests still pass.
- [ ] **Step 5: Commit**
```bash
git add tests/Api/Entity/MachineContextCustomFieldTest.php
git commit -m "test(custom-fields) : add tests for machine context custom fields"
```

View File

@@ -0,0 +1,404 @@
# Machine Context Custom Fields — Frontend Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Parallel plan:** This is the frontend half. The backend plan is at `2026-04-02-machine-context-fields-backend.md`. Both can run in parallel on separate worktrees — they share no files. Frontend tests requiring the API will need the backend done first.
**Goal:** Add `machineContextOnly` toggle in structure editors, filter context fields from standalone pages, and display/edit them in the machine detail view.
**Architecture:** Structure editors get a checkbox per field. The machine-detail transform propagates `contextCustomFields`/`contextCustomFieldValues` from the API link response onto the component/piece objects. Standalone entity views filter these out. Machine view displays them in a separate "Champs contextuels" section using the existing `CustomFieldDisplay` component, saving via upsert with the link ID.
**Tech Stack:** Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5
**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md`
---
## File Map
### Modify
- `frontend/app/shared/types/inventory.ts` — add `machineContextOnly` to custom field types
- `frontend/app/components/PieceModelStructureEditor.vue` — add checkbox toggle
- `frontend/app/composables/usePieceStructureEditorLogic.ts` — add default in `createEmptyField()`
- `frontend/app/components/StructureNodeEditor.vue` — add checkbox toggle
- `frontend/app/composables/useStructureNodeCrud.ts` — add default in `addCustomField()`
- `frontend/app/composables/useEntityCustomFields.ts` — filter out `machineContextOnly` fields
- `frontend/app/composables/useMachineDetailCustomFields.ts` — propagate context fields, filter from normal merge
- `frontend/app/components/ComponentItem.vue` — display context custom fields section
- `frontend/app/components/PieceItem.vue` — display context custom fields section
---
## Task 1: Types — add `machineContextOnly`
**Files:**
- Modify: `frontend/app/shared/types/inventory.ts`
- [ ] **Step 1: Add `machineContextOnly` to `ComponentModelCustomField`**
In the `ComponentModelCustomField` interface (around line 14), add:
```typescript
machineContextOnly?: boolean
```
- [ ] **Step 2: Add `machineContextOnly` to `PieceModelCustomField`**
In the `PieceModelCustomField` interface (around line 65), add:
```typescript
machineContextOnly?: boolean
```
- [ ] **Step 3: Commit**
```bash
cd frontend && git add app/shared/types/inventory.ts
git commit -m "feat(custom-fields) : add machineContextOnly to custom field types"
```
---
## Task 2: Structure editors — add toggle
**Files:**
- Modify: `frontend/app/components/PieceModelStructureEditor.vue:122-125`
- Modify: `frontend/app/composables/usePieceStructureEditorLogic.ts:283-290`
- Modify: `frontend/app/components/StructureNodeEditor.vue:121-125`
- Modify: `frontend/app/composables/useStructureNodeCrud.ts:49-62`
- [ ] **Step 1: Add toggle in `PieceModelStructureEditor.vue`**
After the "Obligatoire" checkbox block (line 125, after `</div>` closing the required checkbox), add:
```vue
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
```
- [ ] **Step 2: Update `usePieceStructureEditorLogic.ts` — 3 functions**
**a) `createEmptyField`** (line 283) — add `machineContextOnly: false` to the returned object:
```typescript
const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid('field'),
name: '',
type: 'text',
required: false,
optionsText: '',
machineContextOnly: false,
orderIndex,
})
```
**b) `toEditorField`** (line 78-91) — add `machineContextOnly` to the returned object, after the `orderIndex` line (line 90):
```typescript
machineContextOnly: Boolean(input?.machineContextOnly),
```
**c) `buildPayload`** (line 160-165) — add `machineContextOnly` to the `payload` object after `orderIndex` (line 164):
```typescript
machineContextOnly: Boolean(field.machineContextOnly),
```
- [ ] **Step 3: Add toggle in `StructureNodeEditor.vue`**
After the "Obligatoire" checkbox closing `</div>` (line 123) and **before** the `<textarea>` for select options (line 124), add:
```vue
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
```
- [ ] **Step 4: Update `addCustomField` in `useStructureNodeCrud.ts`**
In `addCustomField` (line 49), add `machineContextOnly: false` to the pushed object (line 53-60):
```typescript
fields.push({
name: '',
type: 'text',
required: false,
optionsText: '',
options: [],
machineContextOnly: false,
orderIndex: nextIndex,
})
```
- [ ] **Step 5: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
Expected: 0 errors.
- [ ] **Step 6: Commit**
```bash
cd frontend && git add .
git commit -m "feat(custom-fields) : add machineContextOnly toggle in structure editors"
```
---
## Task 3: Filter context fields on standalone pages + machine-detail transform
**Files:**
- Modify: `frontend/app/composables/useEntityCustomFields.ts:42-49`
- Modify: `frontend/app/composables/useMachineDetailCustomFields.ts:141-154,241-256`
- [ ] **Step 1: Filter `machineContextOnly` from `displayedCustomFields` in `useEntityCustomFields.ts`**
Update the `displayedCustomFields` computed (line 42):
```typescript
const displayedCustomFields = computed(() =>
dedupeMergedFields(
mergeFieldDefinitionsWithValues(
definitionSources.value,
entity().customFieldValues,
),
).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly),
)
```
- [ ] **Step 2: Filter `machineContextOnly` from normal customFields in machine-detail transform and propagate context data**
In `frontend/app/composables/useMachineDetailCustomFields.ts`:
**For pieces** — In `transformCustomFields` (line 70), the returned object is built at line 141. Replace the `customFields,` line (line 143) with:
```typescript
customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: piece.contextCustomFields ?? [],
contextCustomFieldValues: piece.contextCustomFieldValues ?? [],
```
**For components** — In `transformComponentCustomFields` (line 158), the returned object is built at line 241. Replace the `customFields,` line (line 243) with:
```typescript
customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: component.contextCustomFields ?? [],
contextCustomFieldValues: component.contextCustomFieldValues ?? [],
```
- [ ] **Step 3: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
Expected: 0 errors.
- [ ] **Step 4: Commit**
```bash
cd frontend && git add .
git commit -m "feat(custom-fields) : filter machineContextOnly from standalone and machine-detail views"
```
---
## Task 4: Display context fields in machine view — `ComponentItem.vue`
**Files:**
- Modify: `frontend/app/components/ComponentItem.vue`
Context fields are on the `component` object (set by the transform in Task 3), not as separate props.
- [ ] **Step 1: Add template section**
After the existing `CustomFieldDisplay` block (line 195), add:
```vue
<!-- Context custom fields (machine-specific) -->
<div v-if="mergedContextFields.length" class="mt-4">
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
Champs contextuels
</h4>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
@field-blur="updateContextCustomField"
/>
</div>
```
- [ ] **Step 2: Add imports and script logic**
**IMPORTANT:** `ComponentItem.vue` uses `<script setup>` WITHOUT `lang="ts"`. Do NOT use TypeScript annotations (`: any`, `: string`, etc.) in any code added to this file.
Add these imports (they are NOT already present in the component):
```javascript
import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
```
Add after the existing `useEntityCustomFields` block (around line 348):
```javascript
const { upsertCustomFieldValue } = useCustomFields()
const { showSuccess, showError } = useToast()
const mergedContextFields = computed(() => {
const definitions = props.component?.contextCustomFields ?? []
const values = props.component?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return dedupeMergedFields(
mergeFieldDefinitionsWithValues(definitions, values),
)
})
const updateContextCustomField = async (field) => {
const linkId = props.component?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId || field.customField?.id
if (!customFieldId) return
const result = await upsertCustomFieldValue(
customFieldId,
'machineComponentLink',
linkId,
field.value ?? '',
)
if (result.success) {
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
} else {
showError(`Erreur lors de la mise à jour du champ contextuel`)
}
}
```
- [ ] **Step 3: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 4: Commit**
```bash
cd frontend && git add app/components/ComponentItem.vue
git commit -m "feat(custom-fields) : display context custom fields in ComponentItem"
```
---
## Task 5: Display context fields in machine view — `PieceItem.vue`
**Files:**
- Modify: `frontend/app/components/PieceItem.vue`
- [ ] **Step 1: Add template section**
After the existing `CustomFieldDisplay` block (line 236), add:
```vue
<!-- Context custom fields (machine-specific) -->
<div v-if="mergedContextFields.length" class="mt-4">
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
Champs contextuels
</h4>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
@field-blur="updateContextCustomField"
/>
</div>
```
- [ ] **Step 2: Add imports and script logic**
**IMPORTANT:** `PieceItem.vue` uses `<script setup>` WITHOUT `lang="ts"`. Do NOT use TypeScript annotations in any code added to this file.
Add these imports (they are NOT already present in the component):
```javascript
import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
```
Add after the existing `useEntityCustomFields` block (around line 366):
```javascript
const { upsertCustomFieldValue } = useCustomFields()
const { showSuccess, showError } = useToast()
const mergedContextFields = computed(() => {
const definitions = props.piece?.contextCustomFields ?? []
const values = props.piece?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return dedupeMergedFields(
mergeFieldDefinitionsWithValues(definitions, values),
)
})
const updateContextCustomField = async (field) => {
const linkId = props.piece?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId || field.customField?.id
if (!customFieldId) return
const result = await upsertCustomFieldValue(
customFieldId,
'machinePieceLink',
linkId,
field.value ?? '',
)
if (result.success) {
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
} else {
showError(`Erreur lors de la mise à jour du champ contextuel`)
}
}
```
- [ ] **Step 3: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 4: Commit**
```bash
cd frontend && git add app/components/PieceItem.vue
git commit -m "feat(custom-fields) : display context custom fields in PieceItem"
```
---
## Task 6: Frontend build verification
- [ ] **Step 1: Run full build**
```bash
cd frontend && npm run build
```
Expected: Build succeeds with no errors.
- [ ] **Step 2: Final commit — update submodule pointer (from main repo)**
```bash
cd /home/matthieu/dev_malio/Inventory
git add frontend
git commit -m "chore : update frontend submodule for context custom fields"
```

View File

@@ -0,0 +1,988 @@
# Custom Fields Simplification — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace 3 parallel custom fields frontend implementations (~2900 lines, 9 files) with a single unified module (~400 lines, 2 files), then migrate all consumers.
**Architecture:** One pure-logic module (`customFields.ts`) with types + helpers, one reactive composable (`useCustomFieldInputs.ts`) wrapping it. The existing API composable `useCustomFields.ts` stays as-is (it's the HTTP layer). The backend already returns a consistent format — only one minor fix needed (add `defaultValue` to serialization groups).
**Tech Stack:** TypeScript, Vue 3 Composition API, Nuxt 4 auto-imports
**Spec:** `docs/superpowers/specs/2026-04-04-custom-fields-simplification-design.md`
---
## File Map
### New files
- `frontend/app/shared/utils/customFields.ts` — types + pure helpers (merge, filter, format, sort)
- `frontend/app/composables/useCustomFieldInputs.ts` — reactive composable wrapping pure helpers + API
### Files to delete (end of migration)
- `frontend/app/shared/utils/entityCustomFieldLogic.ts`
- `frontend/app/shared/utils/customFieldUtils.ts`
- `frontend/app/shared/utils/customFieldFormUtils.ts`
- `frontend/app/composables/useEntityCustomFields.ts`
### Backend file (minor fix)
- `src/Entity/CustomField.php` — add `defaultValue` to serialization groups
### Files to refactor (update imports)
- `frontend/app/composables/useComponentEdit.ts`
- `frontend/app/composables/useComponentCreate.ts`
- `frontend/app/composables/usePieceEdit.ts`
- `frontend/app/composables/useMachineDetailCustomFields.ts`
- `frontend/app/components/ComponentItem.vue`
- `frontend/app/components/PieceItem.vue`
- `frontend/app/components/common/CustomFieldDisplay.vue`
- `frontend/app/components/common/CustomFieldInputGrid.vue`
- `frontend/app/components/machine/MachineCustomFieldsCard.vue`
- `frontend/app/components/machine/MachineInfoCard.vue`
- `frontend/app/pages/pieces/create.vue`
- `frontend/app/pages/product/create.vue`
- `frontend/app/pages/product/[id]/edit.vue`
- `frontend/app/pages/product/[id]/index.vue`
- `frontend/app/shared/model/componentStructure.ts`
- `frontend/app/shared/model/componentStructureSanitize.ts`
- `frontend/app/shared/model/componentStructureHydrate.ts`
---
## Task 1: Backend — Add `defaultValue` to serialization groups
**Files:**
- Modify: `src/Entity/CustomField.php:62-63`
- [ ] **Step 1: Add Groups attribute to defaultValue**
In `src/Entity/CustomField.php`, the `defaultValue` property (line 62-63) currently has no `#[Groups]` attribute. Add it so API Platform includes `defaultValue` in all read responses, matching what `MachineStructureController` already returns.
```php
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?string $defaultValue = null;
```
- [ ] **Step 2: Run linter**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/CustomField.php
git commit -m "fix(api) : expose defaultValue in custom field serialization groups"
```
---
## Task 2: Create unified pure-logic module `customFields.ts`
**Files:**
- Create: `frontend/app/shared/utils/customFields.ts`
This replaces all the types and pure functions from `entityCustomFieldLogic.ts` (335 lines), `customFieldUtils.ts` (440 lines), and `customFieldFormUtils.ts` (404 lines).
- [ ] **Step 1: Write the types and all pure helper functions**
```typescript
/**
* Unified custom field types and pure helpers.
*
* Replaces: entityCustomFieldLogic.ts, customFieldUtils.ts, customFieldFormUtils.ts
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A custom field definition (from ModelType structure or CustomField entity) */
export interface CustomFieldDefinition {
id: string | null
name: string
type: string
required: boolean
options: string[]
defaultValue: string | null
orderIndex: number
machineContextOnly: boolean
}
/** A persisted custom field value (from CustomFieldValue entity via API) */
export interface CustomFieldValue {
id: string
value: string
customField: CustomFieldDefinition
}
/** Merged definition + value for form display and editing */
export interface CustomFieldInput {
customFieldId: string | null
customFieldValueId: string | null
name: string
type: string
required: boolean
options: string[]
defaultValue: string | null
orderIndex: number
machineContextOnly: boolean
value: string
readOnly?: boolean
/** options joined by newline — used by category editor textareas (v-model) */
optionsText?: string
}
// ---------------------------------------------------------------------------
// Normalization — accept any shape, return canonical types
// ---------------------------------------------------------------------------
const ALLOWED_TYPES = ['text', 'number', 'select', 'boolean', 'date'] as const
/**
* Normalize any raw field definition object into a CustomFieldDefinition.
* Handles both standard `{name, type}` and legacy `{key, value: {type}}` formats.
*/
export function normalizeDefinition(raw: any, fallbackIndex = 0): CustomFieldDefinition | null {
if (!raw || typeof raw !== 'object') return null
// Resolve name: standard → legacy key → label
const name = (
typeof raw.name === 'string' ? raw.name.trim() :
typeof raw.key === 'string' ? raw.key.trim() :
typeof raw.label === 'string' ? raw.label.trim() :
''
)
if (!name) return null
// Resolve type: standard → nested in value → fallback
const rawType = (
typeof raw.type === 'string' ? raw.type :
typeof raw.value?.type === 'string' ? raw.value.type :
'text'
).toLowerCase()
const type = ALLOWED_TYPES.includes(rawType as any) ? rawType : 'text'
// Resolve required
const required = typeof raw.required === 'boolean' ? raw.required
: typeof raw.value?.required === 'boolean' ? raw.value.required
: false
// Resolve options
const optionSource = Array.isArray(raw.options) ? raw.options
: Array.isArray(raw.value?.options) ? raw.value.options
: []
const options = optionSource
.map((o: any) => typeof o === 'string' ? o.trim() : typeof o?.value === 'string' ? o.value.trim() : String(o ?? '').trim())
.filter((o: string) => o.length > 0 && o !== '[object Object]')
// Resolve defaultValue
const dv = raw.defaultValue ?? raw.value?.defaultValue ?? null
const defaultValue = dv !== null && dv !== undefined && dv !== '' ? String(dv) : null
// Resolve orderIndex
const orderIndex = typeof raw.orderIndex === 'number' ? raw.orderIndex : fallbackIndex
// Resolve machineContextOnly
const machineContextOnly = !!raw.machineContextOnly
// Resolve id
const id = typeof raw.id === 'string' ? raw.id
: typeof raw.customFieldId === 'string' ? raw.customFieldId
: null
return { id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }
}
/**
* Normalize a raw value entry into a CustomFieldValue.
* Accepts the API format: `{ id, value, customField: {...} }`
*/
export function normalizeValue(raw: any): CustomFieldValue | null {
if (!raw || typeof raw !== 'object') return null
const cf = raw.customField
const definition = normalizeDefinition(cf)
if (!definition) return null
const id = typeof raw.id === 'string' ? raw.id : ''
const value = raw.value !== null && raw.value !== undefined ? String(raw.value) : ''
return { id, value, customField: definition }
}
/**
* Normalize an array of raw definitions into CustomFieldDefinition[].
*/
export function normalizeDefinitions(raw: any): CustomFieldDefinition[] {
if (!Array.isArray(raw)) return []
return raw
.map((item: any, i: number) => normalizeDefinition(item, i))
.filter((d: any): d is CustomFieldDefinition => d !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
/**
* Normalize an array of raw values into CustomFieldValue[].
*/
export function normalizeValues(raw: any): CustomFieldValue[] {
if (!Array.isArray(raw)) return []
return raw
.map((item: any) => normalizeValue(item))
.filter((v: any): v is CustomFieldValue => v !== null)
}
// ---------------------------------------------------------------------------
// Merge — THE one merge function
// ---------------------------------------------------------------------------
/**
* Merge definitions from a ModelType with persisted values from an entity.
* Returns a CustomFieldInput[] ready for form display.
*
* Match strategy: by customField.id first, then by name (case-sensitive).
* When no value exists for a definition, uses defaultValue as initial value.
*/
export function mergeDefinitionsWithValues(
rawDefinitions: any,
rawValues: any,
): CustomFieldInput[] {
const definitions = normalizeDefinitions(rawDefinitions)
const values = normalizeValues(rawValues)
// Build lookup maps for values
const valueById = new Map<string, CustomFieldValue>()
const valueByName = new Map<string, CustomFieldValue>()
for (const v of values) {
if (v.customField.id) valueById.set(v.customField.id, v)
valueByName.set(v.customField.name, v)
}
const matchedValueIds = new Set<string>()
const matchedNames = new Set<string>()
// 1. Map definitions to inputs, matching values
const result: CustomFieldInput[] = definitions.map((def) => {
const matched = (def.id ? valueById.get(def.id) : undefined) ?? valueByName.get(def.name)
const optionsText = def.options.length ? def.options.join('\n') : undefined
if (matched) {
if (matched.id) matchedValueIds.add(matched.id)
matchedNames.add(def.name)
return {
customFieldId: def.id,
customFieldValueId: matched.id || null,
name: def.name,
type: def.type,
required: def.required,
options: def.options,
defaultValue: def.defaultValue,
orderIndex: def.orderIndex,
machineContextOnly: def.machineContextOnly,
value: matched.value,
optionsText,
}
}
// No value found — use defaultValue
return {
customFieldId: def.id,
customFieldValueId: null,
name: def.name,
type: def.type,
required: def.required,
options: def.options,
defaultValue: def.defaultValue,
orderIndex: def.orderIndex,
machineContextOnly: def.machineContextOnly,
value: def.defaultValue ?? '',
optionsText,
}
})
// 2. Add orphan values (have a value but no matching definition)
for (const v of values) {
if (matchedValueIds.has(v.id)) continue
if (matchedNames.has(v.customField.name)) continue
const orphanOptionsText = v.customField.options.length ? v.customField.options.join('\n') : undefined
result.push({
customFieldId: v.customField.id,
customFieldValueId: v.id || null,
name: v.customField.name,
type: v.customField.type,
required: v.customField.required,
options: v.customField.options,
defaultValue: v.customField.defaultValue,
orderIndex: v.customField.orderIndex,
machineContextOnly: v.customField.machineContextOnly,
value: v.value,
optionsText: orphanOptionsText,
})
}
return result.sort((a, b) => a.orderIndex - b.orderIndex)
}
// ---------------------------------------------------------------------------
// Filter & sort
// ---------------------------------------------------------------------------
/** Filter fields by context: standalone hides machineContextOnly, machine shows only machineContextOnly */
export function filterByContext(
fields: CustomFieldInput[],
context: 'standalone' | 'machine',
): CustomFieldInput[] {
if (context === 'machine') return fields.filter((f) => f.machineContextOnly)
return fields.filter((f) => !f.machineContextOnly)
}
/** Sort fields by orderIndex */
export function sortByOrder(fields: CustomFieldInput[]): CustomFieldInput[] {
return [...fields].sort((a, b) => a.orderIndex - b.orderIndex)
}
// ---------------------------------------------------------------------------
// Display helpers
// ---------------------------------------------------------------------------
/** Format a field value for display (e.g. boolean → Oui/Non) */
export function formatValueForDisplay(field: CustomFieldInput): string {
const raw = field.value ?? ''
if (field.type === 'boolean') {
const normalized = String(raw).toLowerCase()
if (normalized === 'true' || normalized === '1') return 'Oui'
if (normalized === 'false' || normalized === '0') return 'Non'
}
return raw || 'Non défini'
}
/** Whether a field has a displayable value (readOnly fields always display) */
export function hasDisplayableValue(field: CustomFieldInput): boolean {
if (field.readOnly) return true
if (field.type === 'boolean') return field.value !== undefined && field.value !== null && field.value !== ''
return typeof field.value === 'string' && field.value.trim().length > 0
}
/** Stable key for v-for rendering */
export function fieldKey(field: CustomFieldInput, index: number): string {
return field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
}
// ---------------------------------------------------------------------------
// Persistence helpers
// ---------------------------------------------------------------------------
/** Whether a field should be persisted (non-empty value) */
export function shouldPersist(field: CustomFieldInput): boolean {
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
return typeof field.value === 'string' && field.value.trim() !== ''
}
/** Format value for save (trim, boolean coercion) */
export function formatValueForSave(field: CustomFieldInput): string {
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
return typeof field.value === 'string' ? field.value.trim() : ''
}
/** Check if all required fields are filled */
export function requiredFieldsFilled(fields: CustomFieldInput[]): boolean {
return fields.every((field) => {
if (!field.required) return true
return shouldPersist(field)
})
}
```
- [ ] **Step 2: Run lint**
```bash
cd frontend && npm run lint:fix
```
- [ ] **Step 3: Commit**
```bash
git add frontend/app/shared/utils/customFields.ts
git commit -m "feat(custom-fields) : add unified pure-logic custom fields module"
```
---
## Task 3: Create unified composable `useCustomFieldInputs.ts`
**Files:**
- Create: `frontend/app/composables/useCustomFieldInputs.ts`
This replaces `useEntityCustomFields.ts` (181 lines) and the custom field parts of `useMachineDetailCustomFields.ts`.
- [ ] **Step 1: Write the composable**
```typescript
/**
* Unified reactive custom field management composable.
*
* Replaces: useEntityCustomFields.ts, custom field parts of useMachineDetailCustomFields.ts,
* and inline custom field logic in useComponentEdit/useComponentCreate/usePieceEdit.
*
* DESIGN NOTE: Uses an internal mutable `ref` (not a `computed`) so that
* save operations can update `customFieldValueId` in place without being
* overwritten on the next reactivity cycle. Call `refresh()` to re-merge
* from the source definitions + values (e.g. after fetching fresh data).
*/
import { ref, watch, computed, type MaybeRef, toValue } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import {
mergeDefinitionsWithValues,
filterByContext,
formatValueForSave,
shouldPersist,
requiredFieldsFilled,
type CustomFieldDefinition,
type CustomFieldValue,
type CustomFieldInput,
} from '~/shared/utils/customFields'
export type { CustomFieldDefinition, CustomFieldValue, CustomFieldInput }
export type CustomFieldEntityType =
| 'machine'
| 'composant'
| 'piece'
| 'product'
| 'machineComponentLink'
| 'machinePieceLink'
export interface UseCustomFieldInputsOptions {
/** Custom field definitions (from ModelType structure or machine.customFields) */
definitions: MaybeRef<any[]>
/** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */
values: MaybeRef<any[]>
/** Entity type for API upsert calls */
entityType: CustomFieldEntityType
/** Entity ID for API upsert calls */
entityId: MaybeRef<string | null>
/** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */
context?: 'standalone' | 'machine'
/** Optional callback to update the source values array after a save (keeps parent reactive state in sync) */
onValueCreated?: (newValue: { id: string; value: string; customField: any }) => void
}
export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
const { entityType, context } = options
const {
updateCustomFieldValue: updateApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Internal mutable state — NOT a computed, so save can mutate in place
const _allFields = ref<CustomFieldInput[]>([])
// Re-merge from source definitions + values
const refresh = () => {
const defs = toValue(options.definitions)
const vals = toValue(options.values)
_allFields.value = mergeDefinitionsWithValues(defs, vals)
}
// Auto-refresh when reactive sources change
watch(
() => [toValue(options.definitions), toValue(options.values)],
() => refresh(),
{ immediate: true, deep: true },
)
// Filtered by context (standalone vs machine)
const fields = computed<CustomFieldInput[]>(() => {
if (!context) return _allFields.value
return filterByContext(_allFields.value, context)
})
// Validation
const requiredFilled = computed(() => requiredFieldsFilled(fields.value))
// Build metadata for upsert when no customFieldId is available (legacy fallback)
const _buildMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
})
// Update a single field value
const update = async (field: CustomFieldInput): Promise<boolean> => {
const id = toValue(options.entityId)
if (!id) {
showError(`Impossible de sauvegarder le champ "${field.name}"`)
return false
}
const value = formatValueForSave(field)
// Update existing value
if (field.customFieldValueId) {
const result: any = await updateApi(field.customFieldValueId, { value })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour`)
return true
}
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
return false
}
// Create new value via upsert — with metadata fallback when no ID
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
const result: any = await upsertCustomFieldValue(
field.customFieldId,
entityType,
id,
value,
metadata,
)
if (result.success) {
// Mutate in place (safe — _allFields is a ref, not computed)
if (result.data?.id) {
field.customFieldValueId = result.data.id
}
if (result.data?.customField?.id) {
field.customFieldId = result.data.customField.id
}
// Notify parent to update its reactive source
if (options.onValueCreated && result.data) {
options.onValueCreated(result.data)
}
showSuccess(`Champ "${field.name}" enregistré`)
return true
}
showError(`Erreur lors de l'enregistrement du champ "${field.name}"`)
return false
}
// Save all fields that have values
const saveAll = async (): Promise<string[]> => {
const id = toValue(options.entityId)
if (!id) return ['(entity ID missing)']
const failed: string[] = []
for (const field of fields.value) {
if (!shouldPersist(field)) continue
const value = formatValueForSave(field)
if (field.customFieldValueId) {
const result: any = await updateApi(field.customFieldValueId, { value })
if (!result.success) failed.push(field.name)
continue
}
// Upsert with metadata fallback when no customFieldId
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
const result: any = await upsertCustomFieldValue(
field.customFieldId,
entityType,
id,
value,
metadata,
)
if (result.success) {
if (result.data?.id) {
field.customFieldValueId = result.data.id
}
if (result.data?.customField?.id) {
field.customFieldId = result.data.customField.id
}
if (options.onValueCreated && result.data) {
options.onValueCreated(result.data)
}
} else {
failed.push(field.name)
}
}
return failed
}
return {
/** All merged fields filtered by context */
fields,
/** All merged fields (unfiltered) */
allFields: _allFields,
/** Whether all required fields have values */
requiredFilled,
/** Update a single field value via API */
update,
/** Save all fields with values, returns list of failed field names */
saveAll,
/** Re-merge from source definitions + values (call after fetching fresh data) */
refresh,
}
}
```
- [ ] **Step 2: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 3: Commit**
```bash
git add frontend/app/composables/useCustomFieldInputs.ts
git commit -m "feat(custom-fields) : add unified useCustomFieldInputs composable"
```
---
## Task 4: Migrate shared components + standalone composables (atomic batch)
**Why batched:** `CustomFieldInputGrid.vue` and `CustomFieldDisplay.vue` receive fields from the composables. Migrating them separately would cause TypeScript errors in the intermediate state. Migrate all together.
**Files:**
- Modify: `frontend/app/components/common/CustomFieldInputGrid.vue`
- Modify: `frontend/app/components/common/CustomFieldDisplay.vue`
- Modify: `frontend/app/composables/useComponentEdit.ts`
- Modify: `frontend/app/composables/useComponentCreate.ts`
- Modify: `frontend/app/composables/usePieceEdit.ts`
- [ ] **Step 1: Migrate `CustomFieldInputGrid.vue`**
Replace the import:
```typescript
// OLD
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
// NEW
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
```
- [ ] **Step 2: Migrate `CustomFieldDisplay.vue`**
Replace all imports from `entityCustomFieldLogic`. With the new typed `CustomFieldInput`, direct property access (`.name`, `.type`, `.options`, `.value`, `.readOnly`) replaces the `resolveFieldXxx()` wrapper functions. Read the full file and adapt the template accordingly.
- [ ] **Step 3: Migrate `useComponentEdit.ts`**
Read the file. Key changes:
1. Replace `customFieldFormUtils` imports with the new module
2. Replace the imperative `refreshCustomFieldInputs` + `buildCustomFieldInputs` pattern with `useCustomFieldInputs`
3. Pass `selectedTypeStructure.value?.customFields` as definitions and `component.value?.customFieldValues` as values
4. Use the `onValueCreated` callback to push new values into `component.value.customFieldValues` so the reactive source stays in sync
5. Replace calls to `refreshCustomFieldInputs()` in watchers/fetch with calls to `refresh()` from the composable
- [ ] **Step 4: Migrate `useComponentCreate.ts`**
Same pattern. Definitions come from `selectedType.value?.structure?.customFields`, values are empty `[]` for creation.
- [ ] **Step 5: Migrate `usePieceEdit.ts`**
Same pattern. Definitions from piece type structure, values from `piece.customFieldValues`.
- [ ] **Step 6: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 7: Verify**
Open each page in the browser:
- `/component/{id}` — check custom fields display and edit
- `/component/create` — check custom fields with default values
- `/pieces/{id}/edit` — check custom fields display and edit
- [ ] **Step 8: Commit**
```bash
git add frontend/app/components/common/CustomFieldInputGrid.vue frontend/app/components/common/CustomFieldDisplay.vue frontend/app/composables/useComponentEdit.ts frontend/app/composables/useComponentCreate.ts frontend/app/composables/usePieceEdit.ts
git commit -m "refactor(custom-fields) : migrate shared components and standalone composables to unified module"
```
---
## Task 5: Migrate standalone pages (product + piece create)
**Files:**
- Modify: `frontend/app/pages/pieces/create.vue`
- Modify: `frontend/app/pages/product/create.vue`
- Modify: `frontend/app/pages/product/[id]/edit.vue`
- Modify: `frontend/app/pages/product/[id]/index.vue`
These pages import directly from `customFieldFormUtils`. Replace with the new module.
- [ ] **Step 1: Read each file and identify all `customFieldFormUtils` imports**
- [ ] **Step 2: For each page, replace imports**
Replace:
```typescript
import { CustomFieldInput, normalizeCustomFieldInputs, buildCustomFieldInputs, requiredCustomFieldsFilled, saveCustomFieldValues } from '~/shared/utils/customFieldFormUtils'
```
With:
```typescript
import { type CustomFieldInput, normalizeDefinitions, mergeDefinitionsWithValues, requiredFieldsFilled } from '~/shared/utils/customFields'
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
```
Adapt the logic in each page to use `useCustomFieldInputs` or the pure helpers as appropriate.
- [ ] **Step 3: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 4: Verify**
Open each page in the browser:
- `/pieces/create` — check custom fields appear when selecting a type
- `/product/create` — same
- `/product/{id}/edit` — check fields display with values
- `/product/{id}` — check read-only display
- [ ] **Step 5: Commit**
```bash
git add frontend/app/pages/pieces/create.vue frontend/app/pages/product/create.vue frontend/app/pages/product/\[id\]/edit.vue frontend/app/pages/product/\[id\]/index.vue
git commit -m "refactor(custom-fields) : migrate product and piece pages to unified module"
```
---
## Task 6: Clean category editor files (`componentStructure*.ts`)
**WHY BEFORE MACHINE PAGE:** `normalizeStructureForEditor` is used by `useMachineDetailCustomFields.ts`. If we change it after migrating the machine page, the machine page would break. So clean this first.
**Files:**
- Modify: `frontend/app/shared/model/componentStructure.ts`
- Modify: `frontend/app/shared/model/componentStructureSanitize.ts`
- Modify: `frontend/app/shared/model/componentStructureHydrate.ts`
- [ ] **Step 1: Read the three files and identify custom field code**
The custom field code in these files handles the category editor (defining fields on a ModelType skeleton). It's the `sanitizeCustomFields` and `hydrateCustomFields` functions, plus custom field handling in `normalizeStructureForEditor` and `normalizeStructureForSave`.
- [ ] **Step 2: Replace custom field sanitize/hydrate with the unified module**
In `componentStructure.ts`, replace the custom field handling in `normalizeStructureForEditor`:
```typescript
// OLD
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
const customFields = sanitizedCustomFields.map((field) => { ... })
// NEW
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
const customFields = mergeDefinitionsWithValues(source.customFields, [])
```
**`optionsText` is now included** in `CustomFieldInput` (added in the type definition). `mergeDefinitionsWithValues` already computes `optionsText` from `options.join('\n')`, so all category editor textareas (`v-model="field.optionsText"`) will work without changes.
- [ ] **Step 3: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 4: Verify TWO things**
1. Open `/component-category/{id}/edit` — check that custom fields are displayed, can be added/removed/reordered, and save correctly.
2. Open `/machine/{id}` — check that the machine page still works (it uses `normalizeStructureForEditor` via `useMachineDetailCustomFields.ts`).
- [ ] **Step 5: Commit**
```bash
git add frontend/app/shared/model/componentStructure.ts frontend/app/shared/model/componentStructureSanitize.ts frontend/app/shared/model/componentStructureHydrate.ts
git commit -m "refactor(custom-fields) : clean category editor structure files"
```
---
## Task 7: Migrate machine page — `MachineCustomFieldsCard`, `MachineInfoCard`, `useMachineDetailCustomFields`
**Depends on:** Task 6 (category editor cleaned — `normalizeStructureForEditor` already uses new types)
**Files:**
- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue`
- Modify: `frontend/app/components/machine/MachineInfoCard.vue`
- Modify: `frontend/app/composables/useMachineDetailCustomFields.ts`
- [ ] **Step 1: Migrate `MachineCustomFieldsCard.vue` and `MachineInfoCard.vue`**
Replace:
```typescript
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
```
With:
```typescript
import { formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/customFields'
```
Replace all calls to `formatCustomFieldValue(field)` with `formatValueForDisplay(field)`.
- [ ] **Step 2: Migrate `useMachineDetailCustomFields.ts` — pure custom field functions**
Replace the following pure-CF functions (~168 lines) with the new module:
| Old function (lines) | Replacement |
|---|---|
| `syncMachineCustomFields` (269-289) | `mergeDefinitionsWithValues(machine.customFields, machine.customFieldValues)` |
| `setMachineCustomFieldValue` (291-299) | Direct property mutation on the mutable `CustomFieldInput` |
| `updateMachineCustomField` (302-363) | `useCustomFieldInputs.update()` |
| `saveAllMachineCustomFields` (461-511) | `useCustomFieldInputs.saveAll()` |
| `saveAllContextCustomFields` (430-459) | Loop over link-level `useCustomFieldInputs` instances |
- [ ] **Step 3: Migrate `useMachineDetailCustomFields.ts` — mixed transform functions**
`transformCustomFields` (lines 71-158) and `transformComponentCustomFields` (lines 161-263) mix custom field merging with constructeur/product/document logic in a single `map()`. Refactor surgically:
**Inside `transformCustomFields` map callback**, replace lines 82-106 (valueEntries + merge + dedupe + filter):
```typescript
// OLD: ~25 lines of valueEntries building, normalizeCustomFieldValueEntry, mergeCustomFieldValuesWithDefinitions, dedupeCustomFieldEntries
// NEW: 2 lines
const customFields = filterByContext(
mergeDefinitionsWithValues(type.customFields ?? typePiece.customFields ?? [], piece.customFieldValues ?? []),
'standalone',
)
```
Keep the rest of the map callback (constructeurs lines 108-133, product lines 119-120, assembly lines 135-158) unchanged.
**Inside `transformComponentCustomFields` map callback**, replace lines 175-199 (same pattern):
```typescript
const customFields = filterByContext(
mergeDefinitionsWithValues(type.customFields ?? [], component.customFieldValues ?? actualComponent?.customFieldValues ?? []),
'standalone',
)
```
Keep recursive calls (lines 201-209) and constructeur/product/document logic (lines 212-263) unchanged.
- [ ] **Step 4: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 5: Verify**
Open a machine page (`/machine/{id}`) that has:
- Machine-level custom fields
- Components with regular custom fields
- Components with machineContextOnly fields
Check display, edit, and save for all three.
- [ ] **Step 6: Commit**
```bash
git add frontend/app/components/machine/MachineCustomFieldsCard.vue frontend/app/components/machine/MachineInfoCard.vue frontend/app/composables/useMachineDetailCustomFields.ts
git commit -m "refactor(custom-fields) : migrate machine page to unified module"
```
---
## Task 8: Migrate hierarchy components (`ComponentItem.vue`, `PieceItem.vue`)
**Depends on:** Task 7 (machine page — parent pre-merges custom fields into `CustomFieldInput[]`)
**Data contract:** The parent (`useMachineDetailCustomFields.transformComponentCustomFields`) already pre-merges custom fields into `component.customFields` (a `CustomFieldInput[]`). The Item components should NOT re-merge — they display the pre-merged data directly and use the API composable only for saves/updates.
**Files:**
- Modify: `frontend/app/components/ComponentItem.vue`
- Modify: `frontend/app/components/PieceItem.vue`
- [ ] **Step 1: Migrate `ComponentItem.vue`**
Read the file. Replace:
```typescript
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
import { resolveFieldKey, resolveFieldName, ... } from '~/shared/utils/entityCustomFieldLogic'
```
With:
```typescript
import { useCustomFields } from '~/composables/useCustomFields'
import { fieldKey, formatValueForDisplay, hasDisplayableValue, mergeDefinitionsWithValues, type CustomFieldInput } from '~/shared/utils/customFields'
```
Key changes:
1. **Remove `useEntityCustomFields`** — the parent already pre-merges. Use `props.component.customFields` directly (already `CustomFieldInput[]` after Task 7)
2. **For display:** use `hasDisplayableValue(field)` and `formatValueForDisplay(field)` instead of `resolveFieldXxx()` wrappers
3. **For edits/saves:** use `useCustomFields()` directly (the HTTP layer) instead of `useEntityCustomFields().updateCustomField`
4. **For context fields** (`component.contextCustomFields` + `component.contextCustomFieldValues`): merge locally with `mergeDefinitionsWithValues` — these are NOT pre-merged by the parent since they come as separate arrays
5. **Replace `resolveCustomFieldId(field)`** with `field.customFieldId` (direct property access on `CustomFieldInput`)
6. **Replace `resolveFieldId(field)`** with `field.customFieldValueId`
- [ ] **Step 2: Migrate `PieceItem.vue`**
Same pattern as ComponentItem but with `entityType: 'piece'` and `props.piece.customFields`.
- [ ] **Step 3: Run lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 4: Verify**
Open a machine page and expand components/pieces in the hierarchy. Check custom fields display correctly.
- [ ] **Step 5: Commit**
```bash
git add frontend/app/components/ComponentItem.vue frontend/app/components/PieceItem.vue
git commit -m "refactor(custom-fields) : migrate ComponentItem and PieceItem to unified module"
```
---
## Task 9: Delete old files + final cleanup
**Files:**
- Delete: `frontend/app/shared/utils/entityCustomFieldLogic.ts`
- Delete: `frontend/app/shared/utils/customFieldUtils.ts`
- Delete: `frontend/app/shared/utils/customFieldFormUtils.ts`
- Delete: `frontend/app/composables/useEntityCustomFields.ts`
- Delete or rewrite: `frontend/tests/shared/customFieldFormUtils.test.ts`
- [ ] **Step 1: Verify no remaining imports of old files**
```bash
cd frontend && grep -r "entityCustomFieldLogic\|customFieldUtils\|customFieldFormUtils\|useEntityCustomFields" app/ --include="*.ts" --include="*.vue" -l
```
Expected: no results (0 files).
- [ ] **Step 2: Delete old files**
```bash
rm frontend/app/shared/utils/entityCustomFieldLogic.ts
rm frontend/app/shared/utils/customFieldUtils.ts
rm frontend/app/shared/utils/customFieldFormUtils.ts
rm frontend/app/composables/useEntityCustomFields.ts
rm frontend/tests/shared/customFieldFormUtils.test.ts
```
- [ ] **Step 3: Run full lint + typecheck**
```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
Expected: 0 errors.
- [ ] **Step 4: Final smoke test**
Test all 4 contexts in the browser:
1. **Machine fields**`/machine/{id}` → machine-level custom fields
2. **Standalone entity**`/component/{id}` → custom fields display and edit
3. **Machine context**`/machine/{id}` → expand a component → machineContextOnly fields
4. **Category editor**`/component-category/{id}/edit` → custom field definitions
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor(custom-fields) : delete old parallel custom field modules"
```

View File

@@ -0,0 +1,148 @@
# Session 04-05 avril 2026 — Refonte UX/UI complète Inventory
## Contexte
L'utilisateur (gestionnaire) remonte que les utilisateurs novices se perdent dans l'app Inventory (gestion d'inventaire industriel : machines, composants, pièces, produits). Ils découvrent le domaine ET l'app en même temps, remplissent les machines depuis de la documentation papier/PDF.
## Ce qui a été fait
### 1. Analyse UX/UI complète
- Exploration en profondeur des 65+ composants, toutes les pages, composables et patterns
- Diagnostic : navigation top-down uniquement, pas de liens inverses, pas de breadcrumbs, navbar mélange tout, pages trop longues, mode lecture ressemble à un formulaire disabled
- Identification de 23 améliorations organisées en 4 phases
### 2. Spec rédigée
**Fichier :** `docs/superpowers/specs/2026-04-04-ux-overhaul-design.md`
23 sections couvrant :
- Réorganisation navbar par domaine métier
- Breadcrumbs contextuels
- Liaisons inverses "Utilisé dans"
- Liens cliquables dans la hiérarchie machine
- Système d'onglets partagé (machine + composant + pièce + produit)
- Pages catalogue unifiées (catalogue + catégories en onglets)
- Recherche globale (**retirée** à la demande de l'utilisateur)
- Raccourcis clavier (**retirés** à la demande)
- Mode lecture texte brut, empty states, toasts, responsive, etc.
### 3. Phase 1 — Quick wins (9 améliorations, 0 backend)
**Plan :** `docs/superpowers/plans/2026-04-04-ux-quick-wins.md`
| Changement | Fichiers modifiés |
|-----------|-------------------|
| Liens cliquables dans hiérarchie machine | ComponentItem, PieceItem, MachineProductsCard |
| Site → machines (badge cliquable) | SiteCard, index.vue |
| Retour contextuel (NuxtLink au lieu de router.back) | DetailHeader |
| Confirmations sur toutes les suppressions | CommentSection, machine/[id].vue |
| Header sticky composants expanded | ComponentItem |
| DataTable fixedLayout opt-in + minWidth | DataTable.vue, dataTable.ts |
| Mode lecture texte brut (26 div-inputs → `<p>`) | MachineInfoCard, 3 pages détail |
| Compteurs titres sections machine | MachineComponentsCard, MachinePiecesCard, MachineDocumentsCard |
| Cohérence fiches (liens catégorie + EntityVersionList) | 3 pages détail entité |
**Review Phase 1 :** a détecté 4 issues corrigées :
- `component.entityId``component.composantId` (property n'existait pas)
- `piece.entityId``piece.pieceId`
- `table-fixed` global → opt-in via prop `fixedLayout`
- NuxtLinks sans `?from=machine&machineId=xxx` → ajouté
### 4. Phase 2 — Refactoring structurel (7 améliorations)
**Plan :** `docs/superpowers/plans/2026-04-04-ux-phase2-structural.md`
| Changement | Fichiers créés/modifiés |
|-----------|------------------------|
| EntityTabs composant partagé | `components/common/EntityTabs.vue` (nouveau) |
| Onglets page machine + header compact | machine/[id].vue, MachineDetailHeader.vue |
| Onglets composant/pièce/produit | 3 pages détail |
| Pages catalogue unifiées /catalogues/* | 3 nouvelles pages + ManagementView modifié |
| Navbar réorganisée (Catalogues + Administration) | AppNavbar.vue |
| Breadcrumbs contextuels | `components/layout/AppBreadcrumb.vue` (nouveau), app.vue |
| Redirections legacy URLs | `middleware/legacy-redirects.global.ts` (nouveau) |
| Guard modifications non sauvegardées | `composables/useUnsavedGuard.ts` (nouveau) |
### 5. Phase 3 — Harmonisation visuelle (3 améliorations)
| Changement | Fichiers |
|-----------|----------|
| EmptyState composant partagé | `components/common/EmptyState.vue` (nouveau), 3 pages |
| Toasts erreur persistent + barre progression | useToast.ts, ToastContainer.vue |
| Responsive mobile (breadcrumbs tronqués, tabs scroll) | AppBreadcrumb, EntityTabs, vérification grids |
### 6. Phase 4 — Backend + reverse links (6 améliorations)
| Changement | Fichiers |
|-----------|----------|
| Endpoint `/api/{entity}/{id}/used-in` | `src/Controller/UsedInController.php` (nouveau) |
| UsedInSection frontend | `composables/useUsedIn.ts` + `components/common/UsedInSection.vue` (nouveaux), 3 pages détail |
| Endpoint `/api/constructeurs/stats` | `src/Controller/ConstructeurStatsController.php` (nouveau) |
| Page fournisseurs enrichie (compteurs cliquables) | constructeurs.vue |
| Endpoint `/api/model_types/{id}/related-items` | `src/Controller/ModelTypeRelatedItemsController.php` (nouveau) |
| Modal catégorie enrichie (machine count + liens) | RelatedItemsModal.vue |
## Bugs découverts et corrigés en cours de route
| Bug | Cause | Fix |
|-----|-------|-----|
| `<script setup>` sans `lang="ts"` | Agents ont ajouté `as string` dans des fichiers JS | Ajouté `lang="ts"` sur ComponentItem, PieceItem, machine/[id] |
| `Cannot access 'selectedType' before initialization` | Bug pré-existant dans usePieceEdit.ts — `resolvedStructure` utilisait `selectedType` avant sa déclaration | Déplacé `resolvedStructure` avant `useCustomFieldInputs` |
| `CommonEmptyState` non résolu | `pathPrefix: false` dans nuxt.config → les composants dans `common/` s'importent sans préfixe | Renommé `CommonEmptyState``EmptyState`, `CommonUsedInSection``UsedInSection` |
| `/api/constructeurs/stats` retourne 404 | Route API Platform `/api/constructeurs/{id}` matchait "stats" comme un {id} | Ajouté `priority: 1` sur la route bulk stats |
| Compteurs fournisseurs tous à 0 | Tables `*_constructeur_links` vides — liens jamais migrés depuis les tables legacy M2M | Restauré depuis backup + créé migration Doctrine |
| Pages `/catalogues/*` manquantes sur le disque | Fichiers committés par agents mais perdus dans le working tree (confusion `frontend/` vs `app/`) | Restauré depuis git history |
## Problème de données découvert
Les **liens constructeur ↔ entités** n'avaient jamais été migrés des anciennes tables ManyToMany (`_composantconstructeurs`, `_piececonstructeurs`) vers les nouvelles tables de liens (`*_constructeur_links`). Ce problème est **pré-existant** au refactoring UX.
### Données restaurées en local
- 3 liens composant-constructeur
- 23 liens pièce-constructeur (dont 6 Limatech remappé avec le nouvel ID)
### Données irrémédiablement perdues (entités supprimées)
- **Convoyeur à Bande** → était lié à Brillaud + Bühler
- **Sangle E12** → était liée à NETCO
- **Arbre du tambour tête E6** → était lié à Dexis
### Migrations créées pour la prod
1. `migrations/Version20260405_MigrateConstructeurLinks.php` — copie depuis les tables legacy M2M (si elles existent)
2. `migrations/Version20260405_RestoreConstructeurLinksFromBackup.php` — fallback : insère directement les données du backup (3), nettoie les orphelins
**Pour restaurer en prod :** `php bin/console doctrine:migrations:migrate`
## Fichiers de référence
| Fichier | Contenu |
|---------|---------|
| `docs/superpowers/specs/2026-04-04-ux-overhaul-design.md` | Spec complète des 23 améliorations |
| `docs/superpowers/plans/2026-04-04-ux-quick-wins.md` | Plan Phase 1 (11 tasks) |
| `docs/superpowers/plans/2026-04-04-ux-phase2-structural.md` | Plan Phase 2 (11 tasks) |
| `docs/superpowers/session-2026-04-04-ux-overhaul.md` | Ce résumé |
## Branche
`feat/ux-quick-wins` — ~30 commits depuis `develop`
## Nouveaux composants/composables créés
- `app/components/common/EntityTabs.vue`
- `app/components/common/EmptyState.vue`
- `app/components/common/UsedInSection.vue`
- `app/components/layout/AppBreadcrumb.vue`
- `app/composables/useUsedIn.ts`
- `app/composables/useUnsavedGuard.ts`
- `app/middleware/legacy-redirects.global.ts`
- `app/pages/catalogues/composants.vue`
- `app/pages/catalogues/pieces.vue`
- `app/pages/catalogues/produits.vue`
## Nouveaux controllers backend
- `src/Controller/UsedInController.php`
- `src/Controller/ConstructeurStatsController.php`
- `src/Controller/ModelTypeRelatedItemsController.php`
## Points d'attention pour la suite
1. **Tester visuellement** toutes les pages sur `localhost:3001` avant merge
2. **Lancer les migrations en prod** pour restaurer les liens constructeur
3. Les anciennes URLs (`/component-catalog`, `/pieces-catalog`, etc.) redirigent automatiquement
4. Le menu Administration n'est visible que pour les gestionnaires/admins (`canEdit`)
5. L'onglet Catégories dans les pages catalogue n'est visible que pour `canEdit`
6. Le `useUnsavedGuard` n'est pas encore intégré dans les pages (composable créé, pas branché)

View File

@@ -18,7 +18,7 @@ L'utilisateur veut pouvoir sélectionner plusieurs sites simultanément.
- Quand **une ou plusieurs** sont cochées → filtre sur ces sites uniquement. - Quand **une ou plusieurs** sont cochées → filtre sur ces sites uniquement.
### Changements techniques ### Changements techniques
**Fichier** : `Inventory_frontend/app/pages/machines/index.vue` **Fichier** : `frontend/app/pages/machines/index.vue`
- **Réactivité** : utiliser `reactive(new Set())` (Vue 3.4+ supporte nativement les mutations `add`/`delete`/`has` sur un Set réactif). Pas de `.value` nécessaire. - **Réactivité** : utiliser `reactive(new Set())` (Vue 3.4+ supporte nativement les mutations `add`/`delete`/`has` sur un Set réactif). Pas de `.value` nécessaire.
- **Note** : le fichier utilise `<script setup>` sans `lang="ts"` — ne pas utiliser d'annotations TypeScript comme `Set<string>`. - **Note** : le fichier utilise `<script setup>` sans `lang="ts"` — ne pas utiliser d'annotations TypeScript comme `Set<string>`.
@@ -36,7 +36,7 @@ Les machines s'affichent dans l'ordre retourné par l'API, sans tri. L'utilisate
Ajouter un `.sort()` avec `localeCompare('fr')` à la fin du computed `filteredMachines`. Ajouter un `.sort()` avec `localeCompare('fr')` à la fin du computed `filteredMachines`.
### Changements techniques ### Changements techniques
**Fichier** : `Inventory_frontend/app/pages/machines/index.vue` **Fichier** : `frontend/app/pages/machines/index.vue`
- Dans le computed `filteredMachines`, ajouter avant le `return` : - Dans le computed `filteredMachines`, ajouter avant le `return` :
```js ```js
@@ -67,9 +67,9 @@ Créer une **Extension Doctrine** (`SearchByNameOrReferenceExtension`) qui inter
- **Pas de conflit** avec le `SearchFilter` existant : le paramètre `q` n'est pas enregistré comme propriété de `SearchFilter`, donc il sera ignoré par celui-ci. Les filtres `name` et `reference` restent disponibles pour d'autres usages. - **Pas de conflit** avec le `SearchFilter` existant : le paramètre `q` n'est pas enregistré comme propriété de `SearchFilter`, donc il sera ignoré par celui-ci. Les filtres `name` et `reference` restent disponibles pour d'autres usages.
**Frontend — 3 fichiers** (dans la fonction `loadXxx`, remplacer l'appel `params.set('name', search.trim())`) : **Frontend — 3 fichiers** (dans la fonction `loadXxx`, remplacer l'appel `params.set('name', search.trim())`) :
- `Inventory_frontend/app/composables/usePieces.ts` → `params.set('q', search.trim())` - `frontend/app/composables/usePieces.ts` → `params.set('q', search.trim())`
- `Inventory_frontend/app/composables/useComposants.ts` → idem - `frontend/app/composables/useComposants.ts` → idem
- `Inventory_frontend/app/composables/useProducts.ts` → idem - `frontend/app/composables/useProducts.ts` → idem
--- ---
@@ -77,11 +77,11 @@ Créer une **Extension Doctrine** (`SearchByNameOrReferenceExtension`) qui inter
| Fichier | Changement | | Fichier | Changement |
|---------|-----------| |---------|-----------|
| `Inventory_frontend/app/pages/machines/index.vue` | Checkboxes sites + tri alphabétique | | `frontend/app/pages/machines/index.vue` | Checkboxes sites + tri alphabétique |
| `src/Doctrine/SearchByNameOrReferenceExtension.php` | **Nouveau** — Extension Doctrine OR search | | `src/Doctrine/SearchByNameOrReferenceExtension.php` | **Nouveau** — Extension Doctrine OR search |
| `Inventory_frontend/app/composables/usePieces.ts` | `name` → `q` | | `frontend/app/composables/usePieces.ts` | `name` → `q` |
| `Inventory_frontend/app/composables/useComposants.ts` | `name` → `q` | | `frontend/app/composables/useComposants.ts` | `name` → `q` |
| `Inventory_frontend/app/composables/useProducts.ts` | `name` → `q` | | `frontend/app/composables/useProducts.ts` | `name` → `q` |
## Hors scope ## Hors scope
- La page Parc Machines cherche **déjà** sur nom ET référence côté frontend (filtrage client-side). Pas de changement nécessaire. - La page Parc Machines cherche **déjà** sur nom ET référence côté frontend (filtrage client-side). Pas de changement nécessaire.

View File

@@ -2,7 +2,7 @@
**Date** : 2026-03-31 **Date** : 2026-03-31
**Scope** : Frontend uniquement (pas de changement backend) **Scope** : Frontend uniquement (pas de changement backend)
**Fichier impacté** : `Inventory_frontend/app/components/model-types/ModelTypeForm.vue` **Fichier impacté** : `frontend/app/components/model-types/ModelTypeForm.vue`
## Problème ## Problème

View File

@@ -0,0 +1,154 @@
# Machine Context Custom Fields
**Date** : 2026-04-02
**Statut** : Validé
## Objectif
Permettre de définir des champs personnalisés sur un ModelType (catégorie de pièce/composant) qui ne s'affichent et ne sont remplissables que lorsque l'item est lié à une machine. Les valeurs sont propres au lien machine (une même pièce dans deux machines peut avoir des valeurs différentes).
## Périmètre
- **Entités concernées** : Composants et Pièces (pas Produits)
- **Définition** : Sur le ModelType, avec un flag `machineContextOnly`
- **Valeurs** : Stockées par lien (`MachineComponentLink` / `MachinePieceLink`)
- **Affichage** : Uniquement dans la vue machine, pas sur les fiches autonomes
## Architecture
### Approche retenue
Extension des entités existantes `CustomField` et `CustomFieldValue` avec :
- Un flag de filtrage sur la définition
- Des FK vers les entités de lien pour les valeurs
### Alternatives écartées
- **Entités séparées** (`MachineContextField` / `MachineContextFieldValue`) — trop de duplication de logique
- **JSON sur les liens** — contraire au projet de normalisation JSON→tables en cours
## Backend
### 1. Entité `CustomField`
Nouveau champ :
```php
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['customField:read', 'customField:write'])]
private bool $machineContextOnly = false;
```
Getter/setter associés.
### 2. Entité `CustomFieldValue`
Nouvelles FK nullable :
```php
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(nullable: true)]
private ?MachineComponentLink $machineComponentLink = null;
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(nullable: true)]
private ?MachinePieceLink $machinePieceLink = null;
```
Contrainte métier : quand `machineComponentLink` est set, `composant` reste aussi set (pour faciliter les queries par composant). Idem pour `machinePieceLink` + `piece`.
### 3. Entités `MachineComponentLink` / `MachinePieceLink`
Nouvelle collection :
```php
#[ORM\OneToMany(targetEntity: CustomFieldValue::class, mappedBy: 'machineComponentLink', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;
```
Idem sur `MachinePieceLink` avec `mappedBy: 'machinePieceLink'`.
### 4. Migration
```sql
ALTER TABLE custom_field ADD machine_context_only BOOLEAN DEFAULT false NOT NULL;
ALTER TABLE custom_field_value ADD machine_component_link_id VARCHAR(36) DEFAULT NULL;
ALTER TABLE custom_field_value ADD machine_piece_link_id VARCHAR(36) DEFAULT NULL;
ALTER TABLE custom_field_value
ADD CONSTRAINT fk_cfv_machine_component_link
FOREIGN KEY (machine_component_link_id) REFERENCES machine_component_link(id) ON DELETE CASCADE;
ALTER TABLE custom_field_value
ADD CONSTRAINT fk_cfv_machine_piece_link
FOREIGN KEY (machine_piece_link_id) REFERENCES machine_piece_link(id) ON DELETE CASCADE;
CREATE INDEX idx_cfv_machine_component_link ON custom_field_value(machine_component_link_id);
CREATE INDEX idx_cfv_machine_piece_link ON custom_field_value(machine_piece_link_id);
```
### 5. `MachineStructureController`
Dans `normalizeComposant()` et `normalizePiece()` :
- Récupérer les `CustomField` du ModelType où `machineContextOnly = true`
- Récupérer les `CustomFieldValue` liées au lien via `machineComponentLink` / `machinePieceLink`
- Ajouter dans la réponse :
```json
{
"contextCustomFields": [{ "id": "...", "name": "...", "type": "...", ... }],
"contextCustomFieldValues": [{ "id": "...", "value": "...", "customField": {...} }]
}
```
Séparé des `customFields` / `customFieldValues` globaux existants.
### 6. `CustomFieldValueController`
L'upsert existant est étendu pour accepter `machineComponentLink` ou `machinePieceLink` dans le body. Le controller vérifie que si le `CustomField` a `machineContextOnly = true`, un lien machine est obligatoire.
### 7. Clonage machine
`MachineStructureController::cloneCustomFields()` doit aussi cloner les `contextFieldValues` des liens, en les rattachant aux nouveaux liens créés lors du clone.
## Frontend
### 1. Page ModelType — Définition des champs
Dans l'UI d'édition des custom fields d'un ModelType, ajouter un **toggle/checkbox** "Contexte machine uniquement" sur chaque définition de champ. Cela set `machineContextOnly: true` lors de la sauvegarde.
Concerne les custom fields des catégories COMPONENT et PIECE (pas PRODUCT, hors périmètre).
### 2. Vue machine — `ComponentItem.vue` / `PieceItem.vue`
Nouvelle section "Champs contextuels" affichée sous les custom fields existants :
- Reçoit `contextCustomFields` et `contextCustomFieldValues` en props
- Réutilise le composant `CustomFieldDisplay.vue` existant
- Mode édition : sur blur/change, appel upsert via `CustomFieldValueController` avec le `machineComponentLinkId` ou `machinePieceLinkId`
### 3. Fiches autonomes pièce/composant
Filtrer les champs `machineContextOnly = true` pour ne pas les afficher :
- Dans `useEntityCustomFields` : exclure ces champs du `displayedCustomFields`
- Dans `useMachineDetailCustomFields` : séparer les champs normaux des champs contextuels
### 4. Transformation des données (`useMachineDetailCustomFields`)
`transformComponentCustomFields()` et `transformCustomFields()` :
- Extraire `contextCustomFields` / `contextCustomFieldValues` depuis la réponse structure
- Les passer en propriétés séparées sur l'objet transformé
## Tests
### Backend
- Test unitaire : `CustomField` avec `machineContextOnly = true` est correctement sérialisé
- Test API : upsert d'un `CustomFieldValue` avec `machineComponentLink` fonctionne
- Test API : upsert d'un `CustomFieldValue` contextuel sans lien machine retourne une erreur
- Test API : `/api/machines/{id}/structure` retourne les `contextCustomFields` et `contextCustomFieldValues`
- Test API : clone machine copie les valeurs contextuelles
### Frontend
- Typecheck : 0 erreurs après modifications
- Vérification manuelle : les champs contextuels apparaissent dans la vue machine
- Vérification manuelle : les champs contextuels n'apparaissent pas sur les fiches autonomes

View File

@@ -0,0 +1,214 @@
# Custom Fields Simplification — Design Spec
**Date:** 2026-04-04
**Scope:** Backend minor cleanup + Frontend unification of the custom fields system
**Constraint:** Everything must work after — progressive migration with verification at each step
## Problem
The custom fields system has grown into 3 parallel frontend implementations (~2900 lines across 9 files) due to accumulated defensive code. This caused data bugs (orphaned fields, lost linkage) and makes every change risky.
## 4 Custom Field Contexts
1. **Machine** — fields defined directly on the machine (`CustomField.machineid` FK), values on machine
2. **Standalone entity** — fields defined in ModelType (category), values on composant/piece/product. Visible when opening the entity directly
3. **Machine context** — fields with `machineContextOnly=true` defined in ModelType, values stored on `MachineComponentLink`/`MachinePieceLink`. Visible only from the machine detail page
4. **Category editor** — UI for defining/editing custom fields in a ModelType skeleton
## Backend Changes
### Minor — format already consistent
After review, `MachineStructureController` already serializes custom fields in the same format as API Platform:
```json
// CustomFieldValue (from normalizeCustomFieldValues)
{
"id": "cfv-123",
"value": "USOCOME",
"customField": {
"id": "cf-456",
"name": "MARQUE",
"type": "text",
"required": false,
"options": [],
"defaultValue": null,
"orderIndex": 0,
"machineContextOnly": false
}
}
```
```json
// CustomField definition (from normalizeCustomFieldDefinitions)
{
"id": "cf-456",
"name": "MARQUE",
"type": "text",
"required": false,
"options": [],
"defaultValue": null,
"orderIndex": 0,
"machineContextOnly": false
}
```
The only backend task is adding `defaultValue` to the API Platform serialization groups on `CustomField.php` so that both API Platform and the custom controller return it.
**Context fields on links** are returned as two separate arrays:
- `contextCustomFields` — definitions filtered to `machineContextOnly=true`
- `contextCustomFieldValues` — values stored on `MachineComponentLink`/`MachinePieceLink`
This format stays as-is. The frontend unified module handles the merge.
**Files:**
- `src/Entity/CustomField.php` — add `#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]` to `defaultValue`
### Legacy `{key, value}` format in DB
`SkeletonStructureService::normalizeCustomFieldData()` accepts two formats:
- Legacy: `{key: "name", value: {type, required, options?, defaultValue?}}`
- Standard: `{name, type, required, options?, defaultValue?}`
**Pre-migration check required:** verify if any `ModelType` rows still have the legacy format in their structure data. If yes, write a one-time DB migration to normalize them before removing the frontend parsing code in Step 5. If no legacy data exists, the parsing code can be safely removed.
## Frontend Changes
### New Unified Module (2 files, ~400 lines total)
**`shared/utils/customFields.ts`** (~180 lines) — Pure logic, zero Vue dependency
Types:
- `CustomFieldDefinition``{ id, name, type, required, options, defaultValue, orderIndex, machineContextOnly }`
- `CustomFieldValue``{ id, value, customField: CustomFieldDefinition }`
- `CustomFieldInput``{ ...CustomFieldDefinition, value, customFieldId, customFieldValueId }` (the merged type used by forms)
Functions:
- `mergeDefinitionsWithValues(definitions, values)``CustomFieldInput[]` — the ONE merge function replacing the 3 current ones. Matches by `customField.id` then by `name`. When no value exists for a definition, uses `defaultValue` as initial value.
- `filterByContext(fields, context: 'standalone' | 'machine')` — filters on `machineContextOnly`
- `sortByOrder(fields)` — sorts by `orderIndex`
- `formatValueForSave(field)` / `shouldPersist(field)` — persistence helpers
- `formatValueForDisplay(field)` — display helper (e.g. boolean → `Oui/Non`), replaces `formatCustomFieldValue` from `customFieldUtils.ts`
- `fieldKey(field, index)` — stable key for v-for, replaces `fieldKey` from `customFieldFormUtils.ts`
**`composables/useCustomFieldInputs.ts`** (~220 lines) — Reactive, wraps pure helpers
```ts
function useCustomFieldInputs(options: {
definitions: MaybeRef<CustomFieldDefinition[]>
values: MaybeRef<CustomFieldValue[]>
entityType: 'machine' | 'composant' | 'piece' | 'product' | 'machineComponentLink' | 'machinePieceLink'
entityId: MaybeRef<string | null>
context?: 'standalone' | 'machine' // defaults to 'standalone'
}): {
fields: ComputedRef<CustomFieldInput[]>
update: (field: CustomFieldInput) => Promise<void>
saveAll: () => Promise<string[]> // returns failed field names
requiredFilled: ComputedRef<boolean>
}
```
**Usage for context 3 (machine context fields on links):**
```ts
// For each MachineComponentLink, instantiate with:
const contextFields = useCustomFieldInputs({
definitions: link.contextCustomFields, // from MachineStructureController
values: link.contextCustomFieldValues, // from MachineStructureController
entityType: 'machineComponentLink',
entityId: link.id,
context: 'machine',
})
```
### Files Deleted After Migration
| File | Lines | Replaced by |
|------|-------|-------------|
| `shared/utils/entityCustomFieldLogic.ts` | 335 | `shared/utils/customFields.ts` |
| `shared/utils/customFieldUtils.ts` | 440 | `shared/utils/customFields.ts` |
| `shared/utils/customFieldFormUtils.ts` | 404 | `shared/utils/customFields.ts` + `composables/useCustomFieldInputs.ts` |
| `composables/useEntityCustomFields.ts` | 181 | `composables/useCustomFieldInputs.ts` |
Additionally refactored (not deleted):
- `composables/useMachineDetailCustomFields.ts` — custom fields code extracted, uses new module (keeps non-CF logic: constructeurs, products, transforms)
- `shared/model/componentStructure.ts` — custom fields code removed (kept: structure/skeleton logic)
- `shared/model/componentStructureSanitize.ts` — custom fields sanitize code removed
- `shared/model/componentStructureHydrate.ts` — custom fields hydrate code removed
### All consuming files to migrate
**Composables:**
- `composables/useComponentEdit.ts` — use `useCustomFieldInputs`
- `composables/useComponentCreate.ts` — use `useCustomFieldInputs`
- `composables/usePieceEdit.ts` — use `useCustomFieldInputs`
- `composables/useMachineDetailCustomFields.ts` — use `useCustomFieldInputs` for all 3 machine sub-cases
**Pages:**
- `pages/component/[id]/index.vue` — already uses composable, minimal changes
- `pages/component/[id]/edit.vue` — already uses composable, minimal changes
- `pages/component/create.vue` — already uses composable, minimal changes
- `pages/pieces/create.vue` — imports from `customFieldFormUtils`, migrate to new types
- `pages/pieces/[id]/edit.vue` — already uses composable, minimal changes
- `pages/product/create.vue` — imports from `customFieldFormUtils`, migrate to new types
- `pages/product/[id]/edit.vue` — imports from `customFieldFormUtils`, migrate to new types
- `pages/product/[id]/index.vue` — imports from `customFieldFormUtils`, migrate to new types
**Shared components:**
- `components/common/CustomFieldDisplay.vue` — imports 7 functions from `entityCustomFieldLogic`, rewrite with unified `CustomFieldInput` type
- `components/common/CustomFieldInputGrid.vue` — imports `fieldKey` + `CustomFieldInput` from `customFieldFormUtils`, update imports
- `components/ComponentItem.vue` — imports from `entityCustomFieldLogic` + `useEntityCustomFields`, migrate
- `components/PieceItem.vue` — imports from `entityCustomFieldLogic` + `useEntityCustomFields`, migrate
- `components/machine/MachineCustomFieldsCard.vue` — imports `formatCustomFieldValue` from `customFieldUtils`, use `formatValueForDisplay`
- `components/machine/MachineInfoCard.vue` — imports `formatCustomFieldValue` from `customFieldUtils`, use `formatValueForDisplay`
- `components/model-types/ModelTypeForm.vue` — use `shared/utils/customFields.ts` types
**Tests:**
- `tests/shared/customFieldFormUtils.test.ts` — rewrite for new module or delete
## Migration Strategy — Progressive (6 steps)
### Step 1: Backend minor fix + DB check
- Add `defaultValue` to serialization groups in `CustomField.php`
- Check DB for legacy `{key, value}` format in `model_types.structure` — write migration if needed
- **Verify:** call `/api/composants/{id}`, confirm `defaultValue` appears in `customField` objects
### Step 2: Create new module
- Write `shared/utils/customFields.ts` and `composables/useCustomFieldInputs.ts`
- Port existing test to new module
- **Verify:** import in a test page, confirm merge/filter/sort/defaultValue work with real data
### Step 3: Migrate standalone pages (composant/piece/product)
- Refactor composables: `useComponentEdit.ts`, `useComponentCreate.ts`, `usePieceEdit.ts`
- Refactor pages: `pieces/create.vue`, `product/create.vue`, `product/[id]/edit.vue`, `product/[id]/index.vue`
- Refactor shared components: `CustomFieldInputGrid.vue`, `CustomFieldDisplay.vue`
- **Verify per page:** open entity, check fields display with values (including defaultValue on new entities), modify a value, confirm save works
### Step 4: Migrate machine page + hierarchy components
- Refactor `useMachineDetailCustomFields.ts` — use `useCustomFieldInputs` for:
- Machine direct fields (definitions from `machine.customFields`, values from `machine.customFieldValues`)
- Standalone component/piece fields (definitions from `type.customFields`, values from entity's `customFieldValues`, filtered `machineContextOnly=false`)
- Machine context fields (definitions from `link.contextCustomFields`, values from `link.contextCustomFieldValues`)
- Refactor `ComponentItem.vue`, `PieceItem.vue` — use `useCustomFieldInputs` instead of `useEntityCustomFields`
- Refactor `MachineCustomFieldsCard.vue`, `MachineInfoCard.vue` — use `formatValueForDisplay`
- **Verify:** open a machine with components that have both normal AND machine-context custom fields, check both display and save correctly
### Step 5: Migrate category editor
- Check DB for legacy `{key, value}` format — run migration if needed
- Clean `componentStructure.ts`, `componentStructureSanitize.ts`, `componentStructureHydrate.ts` — remove custom fields code, use unified types from `customFields.ts`
- Refactor `ModelTypeForm.vue`
- **Verify:** edit a component category, modify skeleton custom fields, save, check linked components see changes
### Step 6: Cleanup
- Delete the 4 old files
- Delete or rewrite `tests/shared/customFieldFormUtils.test.ts`
- `npm run lint:fix` + `npx nuxi typecheck` = 0 errors
- Final smoke test of all 4 contexts
## Result
- **~2900 lines → ~400 lines** + simplified consumers
- **9 custom fields files → 2**
- **3 parallel systems → 1**
- **1 unified data format** understood by all pages
- **`defaultValue` properly handled** across all contexts
- **Legacy format eliminated** from DB and code

View File

@@ -422,16 +422,16 @@ INSERT INTO public.constructeurs (id, name, email, phone, createdat, updatedat)
-- --
-- Data for Name: _composantconstructeurs; Type: TABLE DATA; Schema: public; Owner: - -- Data for Name: composant_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
-- --
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmgz7fd3l009y47fff1l4g0p0', 'cmgqp5dvp00014705qpkci8qc'); INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000001', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgqp5dvp00014705qpkci8qc', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm'); INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000002', 'cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm'); INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000003', 'cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj'); INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000004', 'cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmkqps2h8001q1eq6k2uxopfo', 'cmkqpnznr001p1eq6hdh2ept8'); INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000005', 'cmkqps2h8001q1eq6k2uxopfo', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmkqyn2jm002m1eq6ws83lgwx', 'cmkqpnznr001p1eq6hdh2ept8'); INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000006', 'cmkqyn2jm002m1eq6ws83lgwx', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cl9b1583768c7c9fe6cfe93a11', 'cmkqpnznr001p1eq6hdh2ept8'); INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000007', 'cl9b1583768c7c9fe6cfe93a11', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
-- --
@@ -461,7 +461,7 @@ INSERT INTO public.machines (id, name, reference, prix, createdat, updatedat, si
-- --
-- Data for Name: _machineconstructeurs; Type: TABLE DATA; Schema: public; Owner: - -- Data for Name: machine_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
-- --
@@ -588,25 +588,25 @@ INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, type
-- --
-- Data for Name: _piececonstructeurs; Type: TABLE DATA; Schema: public; Owner: - -- Data for Name: piece_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
-- --
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmizudzfy00021e2w2mtd9zv8', 'cmizu5ugx00011e2wjpr6nb3k'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000001', 'cmizudzfy00021e2w2mtd9zv8', 'cmizu5ugx00011e2wjpr6nb3k', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000002', 'cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000003', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000004', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000005', 'cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000006', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000007', 'cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'cmkqpnznr001p1eq6hdh2ept8'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000008', 'cmkqzl1oa002v1eq6erkt5544', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000009', 'cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000010', 'cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000011', 'cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9sk000047uuwm6u20mj'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000012', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9sk000047uuwm6u20mj', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9te000547uuond39s1c'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000013', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9te000547uuond39s1c', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9tb000447uuuddgakar'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000014', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9tb000447uuuddgakar', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmhaac3vo003547v7s1wv6jhv'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000015', 'cl89d9641d47f52c5385f83d5c', 'cmhaac3vo003547v7s1wv6jhv', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9tm000647uu6em8thyq'); INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000016', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9tm000647uu6em8thyq', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
-- --

View File

@@ -7,6 +7,7 @@
@open-settings="displaySettingsOpen = true" @open-settings="displaySettingsOpen = true"
@logout="handleLogout" @logout="handleLogout"
/> />
<AppBreadcrumb />
<main class="flex-1"> <main class="flex-1">
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" /> <NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />

View File

@@ -255,7 +255,16 @@ const handleResolve = async (commentId: string) => {
} }
} }
const { confirm } = useConfirm()
const handleDelete = async (commentId: string) => { const handleDelete = async (commentId: string) => {
const ok = await confirm({
title: 'Supprimer ce commentaire ?',
message: 'Cette action est irréversible.',
confirmText: 'Supprimer',
dangerous: true,
})
if (!ok) return
const result = await deleteComment(commentId) const result = await deleteComment(commentId)
if (result.success) { if (result.success) {
comments.value = comments.value.filter(c => c.id !== commentId) comments.value = comments.value.filter(c => c.id !== commentId)

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"
@@ -12,6 +12,7 @@
@edit-piece="$emit('edit-piece', $event)" @edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)" @custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('delete')" @delete="$emit('delete')"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/> />
</div> </div>
</div> </div>
@@ -43,5 +44,5 @@ defineProps({
} }
}) })
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete']) defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
</script> </script>

View File

@@ -13,226 +13,333 @@
@updated="handleDocumentUpdated" @updated="handleDocumentUpdated"
/> />
<!-- Component Header --> <!-- HEADER BAR -->
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse"> <div
<IconLucideChevronRight class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
class="w-4 h-4 shrink-0 transition-transform text-base-content/50" :class="[
:class="{ 'rotate-90': !isCollapsed }" component.pendingEntity
aria-hidden="true" ? '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',
<div class="flex-1 min-w-0"> !isCollapsed ? 'sticky top-16 z-10 shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
]"
@click="toggleCollapse"
>
<!-- Chevron -->
<div
class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
:class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
>
<IconLucideChevronRight
class="w-3.5 h-3.5 transition-transform duration-200"
:class="[
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
]"
aria-hidden="true"
/>
</div>
<!-- Content -->
<div class="flex-1 min-w-0 space-y-1.5">
<!-- Row 1: Name + identifiers -->
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-semibold text-base-content truncate"> <h3 class="text-sm font-bold tracking-tight truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
{{ component.name }} <NuxtLink
v-if="!isEditMode && !component.pendingEntity && component.composantId"
:to="machineId
? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } }
: `/component/${component.composantId}`"
class="hover:text-primary transition-colors"
@click.stop
>
{{ component.name }}
</NuxtLink>
<span v-else>{{ component.name }}</span>
</h3> </h3>
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span> <button
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span> v-if="component.pendingEntity"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
title="Cliquer pour associer un item"
@click.stop="$emit('fill-entity', component.linkId, component.modelTypeId)"
>
À remplir
</button>
<span v-if="component.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ component.reference }}</span>
<span v-if="component.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ component.referenceAuto }}</span>
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}</span>
</div> </div>
<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" 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"
@field-blur="updateComponentCustomField"
/>
<!-- Documents -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
<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"
@@ -241,16 +348,20 @@
@update="updatePiece" @update="updatePiece"
@edit="editPiece" @edit="editPiece"
@custom-field-update="updatePieceCustomField" @custom-field-update="updatePieceCustomField"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/> />
</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"
@@ -260,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"
@@ -276,6 +390,7 @@
@update="$emit('update', $event)" @update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)" @edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)" @custom-field-update="$emit('custom-field-update', $event)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/> />
</div> </div>
</div> </div>
@@ -283,13 +398,15 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import PieceItem from './PieceItem.vue' import PieceItem from './PieceItem.vue'
import DocumentUpload from './DocumentUpload.vue' 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 {
@@ -299,7 +416,6 @@ import {
parseConstructeurLinksFromApi, parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils' } from '~/shared/constructeurUtils'
import { import {
formatSize,
shouldInlinePdf, shouldInlinePdf,
documentPreviewSrc, documentPreviewSrc,
documentIcon, documentIcon,
@@ -307,7 +423,11 @@ import {
} from '~/shared/utils/documentDisplayUtils' } from '~/shared/utils/documentDisplayUtils'
import { useEntityDocuments } from '~/composables/useEntityDocuments' import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay' import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
const props = defineProps({ const props = defineProps({
component: { type: Object, required: true }, component: { type: Object, required: true },
@@ -317,7 +437,7 @@ const props = defineProps({
toggleToken: { type: Number, default: 0 }, toggleToken: { type: Number, default: 0 },
}) })
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete']) const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
// --- Shared composables --- // --- Shared composables ---
const { const {
@@ -343,9 +463,111 @@ const {
} = useEntityProductDisplay({ entity: () => props.component }) } = useEntityProductDisplay({ entity: () => props.component })
const { const {
displayedCustomFields, updateCustomFieldValue: updateCustomFieldValueApi,
updateCustomField: updateComponentCustomField, upsertCustomFieldValue,
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' }) } = useCustomFields()
const { showSuccess, showError } = useToast()
// Parent already pre-merges standalone custom fields into props.component.customFields
const displayedCustomFields = computed(() => {
const fields = props.component?.customFields
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
})
const updateComponentCustomField = async (field) => {
if (!field || field.readOnly) return
const e = props.component
const fieldValueId = field.customFieldValueId
if (fieldValueId) {
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
}
return
}
if (!e?.id) {
showError('Impossible de créer la valeur pour ce champ')
return
}
const metadata = field.customFieldId ? undefined : {
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
}
const result = await upsertCustomFieldValue(
field.customFieldId,
'composant',
e.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
}
}
showSuccess(`Champ "${field.name}" créé avec succès`)
} else {
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
}
}
// Context fields are NOT pre-merged — merge locally
const mergedContextFields = computed(() => {
const definitions = props.component?.contextCustomFields ?? []
const values = props.component?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return mergeDefinitionsWithValues(definitions, values)
})
// Context fields shown as tags on the header (consultation mode)
const visibleContextFieldTags = computed(() =>
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
)
const CONTEXT_FIELD_COLORS = [
'bg-secondary/25 text-secondary border border-secondary/35',
'bg-accent/25 text-accent border border-accent/35',
'bg-info/25 text-info border border-info/35',
'bg-success/25 text-success border border-success/35',
'bg-warning/25 text-warning border border-warning/35',
]
const contextFieldBadgeClass = (field: any) => {
const idx = visibleContextFieldTags.value.indexOf(field)
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
}
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.component?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId
const customFieldValueId = field.customFieldValueId
if (!customFieldId && !customFieldValueId) return
field.value = value
emit('custom-field-update', {
entityType: 'machineComponentLink',
entityId: linkId,
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || 'Champ contextuel',
})
}
// --- Document edit modal --- // --- Document edit modal ---
const editingDocument = ref(null) const editingDocument = ref(null)

View File

@@ -15,9 +15,10 @@
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" /> <IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ isEditMode ? 'Voir détails' : 'Modifier' }} {{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button> </button>
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack"> <NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md">
Retour au catalogue <IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
</button> {{ backLabel }}
</NuxtLink>
</div> </div>
</div> </div>
</template> </template>
@@ -25,8 +26,9 @@
<script setup lang="ts"> <script setup lang="ts">
import IconLucideSquarePen from '~icons/lucide/square-pen' import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye' import IconLucideEye from '~icons/lucide/eye'
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const router = useRouter() const route = useRoute()
const props = defineProps<{ const props = defineProps<{
title: string title: string
@@ -34,18 +36,24 @@ const props = defineProps<{
isEditMode: boolean isEditMode: boolean
canEdit: boolean canEdit: boolean
backLink: string backLink: string
backLinkLabel?: string
}>() }>()
defineEmits<{ defineEmits<{
'toggle-edit': [] 'toggle-edit': []
}>() }>()
function goBack() { const backDestination = computed(() => {
if (window.history.length > 1) { if (route.query.from === 'machine' && route.query.machineId) {
router.back() return `/machine/${route.query.machineId}`
} }
else { return props.backLink
navigateTo(props.backLink) })
const backLabel = computed(() => {
if (route.query.from === 'machine') {
return 'Retour à la machine'
} }
} return props.backLinkLabel ?? 'Retour au catalogue'
})
</script> </script>

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,276 +13,346 @@
@updated="handleDocumentUpdated" @updated="handleDocumentUpdated"
/> />
<!-- Piece Header (collapsible, same pattern as ComponentItem) --> <!-- HEADER BAR -->
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg"> <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"> >
{{ pieceData.name }} <IconLucideChevronRight
<span class="w-3.5 h-3.5 transition-transform duration-200"
v-if="displayQuantity > 1" :class="[
class="text-sm font-normal text-base-content/60 ml-1" isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
]"
aria-hidden="true"
/>
</div>
<!-- Content -->
<div class="flex-1 min-w-0 space-y-1.5">
<!-- Row 1: Name + identifiers -->
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-bold tracking-tight truncate" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
<NuxtLink
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
:to="machineId
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
: `/piece/${piece.pieceId}`"
class="hover:text-primary transition-colors"
@click.stop
> >
×{{ displayQuantity }} {{ pieceData.name }}
</span> </NuxtLink>
<template v-else>{{ pieceData.name }}</template>
</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" 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>
@field-input="handleCustomFieldInput" </div>
@field-blur="handleCustomFieldBlur" <div class="p-4">
/> <CustomFieldDisplay
:fields="mergedContextFields"
<div class="mt-4 pt-4 border-t border-base-200 space-y-3"> :is-edit-mode="isEditMode"
<div class="flex items-center justify-between"> :columns="2"
<h5 class="text-sm font-medium text-base-content/80">Documents</h5> :show-header="false"
<span :with-top-border="false"
v-if="isEditMode && selectedFiles.length" :editable="true"
class="badge badge-outline" :emit-blur="false"
> @field-input="queueContextCustomFieldUpdate"
{{ selectedFiles.length }} fichier{{ />
selectedFiles.length > 1 ? "s" : "" </div>
}}
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>
<script setup> <script setup lang="ts">
import { reactive, ref, onMounted, watch, computed } from 'vue' import { reactive, ref, onMounted, watch, computed } from 'vue'
import ConstructeurSelect from './ConstructeurSelect.vue' import ConstructeurSelect from './ConstructeurSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue' 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 {
@@ -291,13 +361,13 @@ import {
uniqueConstructeurIds, uniqueConstructeurIds,
parseConstructeurLinksFromApi, parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils' } from '~/shared/constructeurUtils'
import { import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
resolveFieldId, import { useCustomFields } from '~/composables/useCustomFields'
resolveFieldReadOnly,
} from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments' import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay' import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
const props = defineProps({ const props = defineProps({
piece: { type: Object, required: true }, piece: { type: Object, required: true },
@@ -307,7 +377,7 @@ const props = defineProps({
toggleToken: { type: Number, default: 0 }, toggleToken: { type: Number, default: 0 },
}) })
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete']) const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete', 'fill-entity'])
// --- Local reactive data for editing --- // --- Local reactive data for editing ---
const pieceData = reactive({ const pieceData = reactive({
@@ -361,9 +431,111 @@ const {
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct }) } = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
const { const {
displayedCustomFields, updateCustomFieldValue: updateCustomFieldValueApi,
updateCustomField, upsertCustomFieldValue,
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' }) } = useCustomFields()
const { showSuccess, showError } = useToast()
// Parent already pre-merges standalone custom fields into props.piece.customFields
const displayedCustomFields = computed(() => {
const fields = props.piece?.customFields
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
})
const updateCustomField = async (field) => {
if (!field || field.readOnly) return
const e = props.piece
const fieldValueId = field.customFieldValueId
if (fieldValueId) {
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
}
return
}
if (!e?.id) {
showError('Impossible de créer la valeur pour ce champ')
return
}
const metadata = field.customFieldId ? undefined : {
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
}
const result = await upsertCustomFieldValue(
field.customFieldId,
'piece',
e.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
}
}
showSuccess(`Champ "${field.name}" créé avec succès`)
} else {
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
}
}
// Context fields are NOT pre-merged — merge locally
const mergedContextFields = computed(() => {
const definitions = props.piece?.contextCustomFields ?? []
const values = props.piece?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return mergeDefinitionsWithValues(definitions, values)
})
// Context fields shown as tags on the header (consultation mode)
const visibleContextFieldTags = computed(() =>
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
)
const CONTEXT_FIELD_COLORS = [
'bg-secondary/25 text-secondary border border-secondary/35',
'bg-accent/25 text-accent border border-accent/35',
'bg-info/25 text-info border border-info/35',
'bg-success/25 text-success border border-success/35',
'bg-warning/25 text-warning border border-warning/35',
]
const contextFieldBadgeClass = (field) => {
const idx = visibleContextFieldTags.value.indexOf(field)
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
}
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.piece?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId
const customFieldValueId = field.customFieldValueId
if (!customFieldId && !customFieldValueId) return
field.value = value
emit('custom-field-update', {
entityType: 'machinePieceLink',
entityId: linkId,
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || 'Champ contextuel',
})
}
// --- Document edit modal --- // --- Document edit modal ---
const editingDocument = ref(null) const editingDocument = ref(null)
@@ -485,8 +657,8 @@ const handleProductChange = async (value) => {
// --- Custom field event handlers --- // --- Custom field event handlers ---
const handleCustomFieldInput = (field, value) => { const handleCustomFieldInput = (field, value) => {
if (resolveFieldReadOnly(field)) return if (field.readOnly) return
const fieldValueId = resolveFieldId(field) const fieldValueId = field.customFieldValueId
if (!fieldValueId) return if (!fieldValueId) return
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId) const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
if (fieldValue) fieldValue.value = value if (fieldValue) fieldValue.value = value
@@ -494,7 +666,7 @@ const handleCustomFieldInput = (field, value) => {
const handleCustomFieldBlur = async (field) => { const handleCustomFieldBlur = async (field) => {
await updateCustomField(field) await updateCustomField(field)
const cfId = field?.customFieldId || field?.customField?.id || null const cfId = field?.customFieldId || null
if (cfId || field?.customFieldValueId) { if (cfId || field?.customFieldValueId) {
emit('custom-field-update', { emit('custom-field-update', {
fieldId: cfId, fieldId: cfId,
@@ -567,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
@@ -124,6 +124,11 @@
Obligatoire Obligatoire
</div> </div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
<textarea <textarea
v-if="field.type === 'select'" v-if="field.type === 'select'"
v-model="field.optionsText" v-model="field.optionsText"
@@ -161,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

@@ -121,6 +121,10 @@
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" /> <input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
Obligatoire Obligatoire
</div> </div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
<textarea <textarea
v-if="field.type === 'select'" v-if="field.type === 'select'"
v-model="field.optionsText" v-model="field.optionsText"

View File

@@ -13,7 +13,7 @@
]" ]"
> >
<div <div
class="alert toast-card shadow-md px-3 py-2 text-sm" class="alert toast-card relative shadow-md px-3 py-2 text-sm overflow-hidden"
:class="getToastClasses(toast.type)" :class="getToastClasses(toast.type)"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -54,13 +54,20 @@
<IconLucideX class="w-3 h-3" aria-hidden="true" /> <IconLucideX class="w-3 h-3" aria-hidden="true" />
</button> </button>
</div> </div>
<!-- Progress bar for auto-dismiss toasts -->
<div
v-if="toast.duration > 0"
class="absolute bottom-0 left-0 h-0.5 bg-current opacity-30 rounded-full"
:style="{ animation: `toast-progress ${toast.duration}ms linear forwards` }"
/>
</div> </div>
</div> </div>
</TransitionGroup> </TransitionGroup>
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import IconLucideCheck from '~icons/lucide/check' import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x' import IconLucideX from '~icons/lucide/x'
@@ -70,7 +77,7 @@ import IconLucideInfo from '~icons/lucide/info'
const { toasts, removeToast } = useToast() const { toasts, removeToast } = useToast()
const getToastClasses = (type) => { const getToastClasses = (type: ToastType) => {
switch (type) { switch (type) {
case 'success': case 'success':
return 'alert-success text-success-content' return 'alert-success text-success-content'
@@ -111,4 +118,9 @@ const getToastClasses = (type) => {
pointer-events: auto; pointer-events: auto;
border-radius: 0.75rem; border-radius: 0.75rem;
} }
@keyframes toast-progress {
from { width: 100%; }
to { width: 0%; }
}
</style> </style>

View File

@@ -1,57 +1,57 @@
<template> <template>
<div <div
v-if="fields.length" v-if="fields.length"
class="mt-4 pt-4 border-t border-base-200" :class="containerClass"
> >
<h5 class="text-sm font-medium text-base-content/80 mb-3"> <h5 v-if="showHeader" class="text-sm font-medium text-base-content/80 mb-3">
Champs personnalisés {{ title }}
</h5> </h5>
<div :class="layoutClass"> <div :class="layoutClass">
<div <div
v-for="(field, index) in fields" v-for="(field, index) in fields"
:key="resolveFieldKey(field, index)" :key="fieldKey(field, index)"
class="form-control" class="form-control"
> >
<label class="label"> <label class="label">
<span class="label-text text-sm">{{ <span class="label-text text-sm">{{
resolveFieldName(field) field.name
}}</span> }}</span>
<span <span
v-if="resolveFieldRequired(field)" v-if="field.required"
class="label-text-alt text-error" class="label-text-alt text-error"
>*</span> >*</span>
</label> </label>
<!-- Mode édition --> <!-- Mode édition -->
<template v-if="isEditMode && !resolveFieldReadOnly(field)"> <template v-if="isFieldEditable(field)">
<!-- Champ de type TEXT --> <!-- Champ de type TEXT -->
<input <input
v-if="resolveFieldType(field) === 'text'" v-if="field.type === 'text'"
:value="field.value ?? ''" :value="field.value ?? ''"
type="text" type="text"
class="input input-bordered input-sm" class="input input-bordered input-sm"
:required="resolveFieldRequired(field)" :required="field.required"
@input="onInput(field, ($event.target as HTMLInputElement).value)" @input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)" @blur="onBlur(field)"
> >
<!-- Champ de type NUMBER --> <!-- Champ de type NUMBER -->
<input <input
v-else-if="resolveFieldType(field) === 'number'" v-else-if="field.type === 'number'"
:value="field.value ?? ''" :value="field.value ?? ''"
type="number" type="number"
class="input input-bordered input-sm" class="input input-bordered input-sm"
:required="resolveFieldRequired(field)" :required="field.required"
@input="onInput(field, ($event.target as HTMLInputElement).value)" @input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)" @blur="onBlur(field)"
> >
<!-- Champ de type SELECT --> <!-- Champ de type SELECT -->
<select <select
v-else-if="resolveFieldType(field) === 'select'" v-else-if="field.type === 'select'"
:value="field.value ?? ''" :value="field.value ?? ''"
class="select select-bordered select-sm" class="select select-bordered select-sm"
:required="resolveFieldRequired(field)" :required="field.required"
@change="onInput(field, ($event.target as HTMLSelectElement).value)" @change="onInput(field, ($event.target as HTMLSelectElement).value)"
@blur="onBlur(field)" @blur="onBlur(field)"
> >
@@ -59,7 +59,7 @@
Sélectionner... Sélectionner...
</option> </option>
<option <option
v-for="option in resolveFieldOptions(field)" v-for="option in field.options"
:key="option" :key="option"
:value="option" :value="option"
> >
@@ -69,7 +69,7 @@
<!-- Champ de type BOOLEAN --> <!-- Champ de type BOOLEAN -->
<div <div
v-else-if="resolveFieldType(field) === 'boolean'" v-else-if="field.type === 'boolean'"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<input <input
@@ -85,21 +85,21 @@
<!-- Champ de type DATE --> <!-- Champ de type DATE -->
<input <input
v-else-if="resolveFieldType(field) === 'date'" v-else-if="field.type === 'date'"
:value="field.value ?? ''" :value="field.value ?? ''"
type="date" type="date"
class="input input-bordered input-sm" class="input input-bordered input-sm"
:required="resolveFieldRequired(field)" :required="field.required"
@input="onInput(field, ($event.target as HTMLInputElement).value)" @input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)" @blur="onBlur(field)"
> >
<!-- Champ de type TEXTAREA --> <!-- Champ de type TEXTAREA -->
<textarea <textarea
v-else-if="resolveFieldType(field) === 'textarea'" v-else-if="field.type === 'textarea'"
:value="field.value ?? ''" :value="field.value ?? ''"
class="textarea textarea-bordered textarea-sm" class="textarea textarea-bordered textarea-sm"
:required="resolveFieldRequired(field)" :required="field.required"
@input="onInput(field, ($event.target as HTMLTextAreaElement).value)" @input="onInput(field, ($event.target as HTMLTextAreaElement).value)"
@blur="onBlur(field)" @blur="onBlur(field)"
/> />
@@ -110,7 +110,7 @@
:value="field.value ?? ''" :value="field.value ?? ''"
type="text" type="text"
class="input input-bordered input-sm" class="input input-bordered input-sm"
:required="resolveFieldRequired(field)" :required="field.required"
@input="onInput(field, ($event.target as HTMLInputElement).value)" @input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)" @blur="onBlur(field)"
> >
@@ -119,7 +119,7 @@
<!-- Mode lecture seule --> <!-- Mode lecture seule -->
<template v-else> <template v-else>
<div class="input input-bordered input-sm bg-base-200"> <div class="input input-bordered input-sm bg-base-200">
{{ formatFieldDisplayValue(field) }} {{ formatValueForDisplay(field) }}
</div> </div>
</template> </template>
</div> </div>
@@ -128,25 +128,22 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { fieldKey, formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/customFields'
resolveFieldKey,
resolveFieldName,
resolveFieldType,
resolveFieldOptions,
resolveFieldRequired,
resolveFieldReadOnly,
formatFieldDisplayValue,
} from '~/shared/utils/entityCustomFieldLogic'
const props = defineProps<{ const props = defineProps<{
fields: any[] fields: CustomFieldInput[]
isEditMode: boolean isEditMode: boolean
columns?: 1 | 2 columns?: 1 | 2
title?: string
showHeader?: boolean
withTopBorder?: boolean
editable?: boolean
emitBlur?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'field-input': [field: any, value: string] 'field-input': [field: CustomFieldInput, value: string]
'field-blur': [field: any] 'field-blur': [field: CustomFieldInput]
}>() }>()
const layoutClass = computed(() => const layoutClass = computed(() =>
@@ -155,19 +152,37 @@ const layoutClass = computed(() =>
: 'space-y-3', : 'space-y-3',
) )
function onInput(field: any, value: string) { const title = computed(() => props.title ?? 'Champs personnalisés')
const showHeader = computed(() => props.showHeader ?? true)
const containerClass = computed(() =>
props.withTopBorder === false
? ''
: 'mt-4 pt-4 border-t border-base-200',
)
const editable = computed(() => props.editable ?? true)
const emitBlur = computed(() => props.emitBlur ?? true)
function isFieldEditable(field: CustomFieldInput) {
return props.isEditMode && editable.value && !field.readOnly
}
function onInput(field: CustomFieldInput, value: string) {
field.value = value field.value = value
emit('field-input', field, value) emit('field-input', field, value)
} }
function onBooleanChange(field: any, checked: boolean) { function onBooleanChange(field: CustomFieldInput, checked: boolean) {
const value = checked ? 'true' : 'false' const value = checked ? 'true' : 'false'
field.value = value field.value = value
emit('field-input', field, value) emit('field-input', field, value)
emit('field-blur', field) if (emitBlur.value) {
emit('field-blur', field)
}
} }
function onBlur(field: any) { function onBlur(field: CustomFieldInput) {
emit('field-blur', field) if (emitBlur.value) {
emit('field-blur', field)
}
} }
</script> </script>

View File

@@ -74,7 +74,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils' import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
defineProps<{ defineProps<{
fields: CustomFieldInput[] fields: CustomFieldInput[]

View File

@@ -71,7 +71,7 @@
> >
<span class="loading loading-spinner text-primary" aria-hidden="true" /> <span class="loading loading-spinner text-primary" aria-hidden="true" />
</div> </div>
<table :class="['table table-sm md:table-md', tableClass]"> <table :class="['table table-sm md:table-md', tableClass, { 'table-fixed': fixedLayout }]">
<thead> <thead>
<!-- Header labels + sort --> <!-- Header labels + sort -->
<tr> <tr>
@@ -85,6 +85,7 @@
alignClass(col), alignClass(col),
{ 'hidden sm:table-cell': col.hiddenMobile }, { 'hidden sm:table-cell': col.hiddenMobile },
]" ]"
:style="col.minWidth ? { minWidth: col.minWidth } : undefined"
> >
<slot :name="`header-${col.key}`" :column="col"> <slot :name="`header-${col.key}`" :column="col">
<span <span
@@ -221,6 +222,8 @@ const props = withDefaults(defineProps<{
tableClass?: string tableClass?: string
showCounter?: boolean showCounter?: boolean
showPerPage?: boolean showPerPage?: boolean
/** Use table-layout: fixed for stable column widths. Only enable on tables where columns define width/minWidth. */
fixedLayout?: boolean
}>(), { }>(), {
rowKey: 'id', rowKey: 'id',
loading: false, loading: false,

View File

@@ -0,0 +1,33 @@
<template>
<div class="text-center py-12">
<div v-if="icon" class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<component :is="icon" class="w-8 h-8 text-base-content/30" aria-hidden="true" />
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">{{ title }}</h3>
<p v-if="description" class="text-sm text-base-content/50 mb-6">{{ description }}</p>
<slot>
<NuxtLink v-if="actionTo" :to="actionTo" class="btn btn-primary btn-sm">
{{ actionLabel }}
</NuxtLink>
<button v-else-if="actionLabel" type="button" class="btn btn-primary btn-sm" @click="$emit('action')">
{{ actionLabel }}
</button>
</slot>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
title: string
description?: string
icon?: Component
actionLabel?: string
actionTo?: string
}>()
defineEmits<{
action: []
}>()
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div>
<nav class="tabs tabs-bordered mb-6 overflow-x-auto flex-nowrap" role="tablist" :aria-label="ariaLabel">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
class="tab"
:class="{ 'tab-active': modelValue === tab.key }"
role="tab"
:aria-selected="modelValue === tab.key"
@click="emit('update:modelValue', tab.key)"
>
{{ tab.label }}
<span v-if="tab.count !== undefined && tab.count > 0" class="badge badge-outline badge-xs ml-1.5">
{{ tab.count }}
</span>
</button>
</nav>
<div role="tabpanel">
<slot :name="`tab-${modelValue}`" />
</div>
</div>
</template>
<script setup lang="ts">
export interface TabDefinition {
key: string
label: string
count?: number
}
defineProps<{
tabs: TabDefinition[]
modelValue: string
ariaLabel?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div v-if="!loading && totalCount > 0" class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4">
<h3 class="font-semibold text-base-content">Utilisé dans</h3>
<div v-if="data.machines.length" class="space-y-1">
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Machines</p>
<div v-for="m in data.machines" :key="m.id" class="flex items-center gap-2 text-sm">
<NuxtLink :to="`/machine/${m.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
{{ m.name }}
</NuxtLink>
<span v-if="m.site?.name" class="badge badge-ghost badge-xs">{{ m.site.name }}</span>
</div>
</div>
<div v-if="data.composants.length" class="space-y-1">
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Composants</p>
<div v-for="c in data.composants" :key="c.id" class="text-sm">
<NuxtLink :to="`/component/${c.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
{{ c.name }}
</NuxtLink>
</div>
</div>
<div v-if="data.pieces.length" class="space-y-1">
<p class="text-xs font-medium text-base-content/60 uppercase tracking-wide">Pièces</p>
<div v-for="p in data.pieces" :key="p.id" class="text-sm">
<NuxtLink :to="`/piece/${p.id}`" class="hover:underline hover:text-primary transition-colors font-medium">
{{ p.name }}
</NuxtLink>
</div>
</div>
</div>
<div v-else-if="loading" class="flex justify-center py-4">
<span class="loading loading-spinner loading-sm" />
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
entityType: 'composants' | 'pieces' | 'products'
entityId: string | null
}>()
const { data, loading, totalCount } = useUsedIn(
computed(() => props.entityType),
computed(() => props.entityId),
)
</script>

View File

@@ -0,0 +1,137 @@
<template>
<nav v-if="crumbs.length > 1" class="container mx-auto px-6 pt-4" aria-label="Fil d'Ariane">
<div class="text-sm breadcrumbs py-0">
<ul>
<!-- First crumb (always visible) -->
<li>
<NuxtLink :to="crumbs[0].path" class="text-base-content/60 hover:text-primary transition-colors">
{{ crumbs[0].label }}
</NuxtLink>
</li>
<!-- Ellipsis on mobile when there are middle crumbs -->
<li v-if="crumbs.length > 2" class="sm:hidden">
<span class="text-base-content/40"></span>
</li>
<!-- Middle crumbs: hidden on mobile, visible sm+ -->
<li
v-for="(crumb, i) in crumbs.slice(1, crumbs.length - 1)"
:key="i"
class="hidden sm:list-item"
>
<NuxtLink :to="crumb.path" class="text-base-content/60 hover:text-primary transition-colors">
{{ crumb.label }}
</NuxtLink>
</li>
<!-- Last crumb (always visible, current page) -->
<li v-if="crumbs.length > 1">
<span class="text-base-content font-medium">{{ crumbs[crumbs.length - 1].label }}</span>
</li>
</ul>
</div>
</nav>
</template>
<script setup lang="ts">
interface Crumb {
label: string
path: string
}
const route = useRoute()
const crumbs = computed<Crumb[]>(() => {
const result: Crumb[] = [{ label: 'Accueil', path: '/' }]
const path = route.path
// Home page — no breadcrumb
if (path === '/') return []
// Machine context from query param (when navigating from a machine detail page)
if (route.query.from === 'machine' && route.query.machineId) {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` })
}
// Machines
if (path === '/machines') {
result.push({ label: 'Parc machines', path: '/machines' })
} else if (path.startsWith('/machine/') && !route.query.from) {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Machine', path })
}
// Catalogs
else if (path.startsWith('/catalogues/composants')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
} else if (path.startsWith('/catalogues/pieces')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
} else if (path.startsWith('/catalogues/produits')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
}
// Entity detail pages (when NOT from machine context)
else if (path.startsWith('/component/') && !route.query.from) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Composant', path })
} else if (path.startsWith('/piece/') && !route.query.from) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Pièce', path })
} else if (path.startsWith('/product/') && !route.query.from) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Produit', path })
}
// Entity detail pages WITH machine context — add entity as last crumb
else if (path.startsWith('/component/') && route.query.from === 'machine') {
result.push({ label: 'Composant', path })
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
result.push({ label: 'Pièce', path })
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
result.push({ label: 'Produit', path })
}
// Admin pages
else if (path.startsWith('/sites')) {
result.push({ label: 'Sites', path: '/sites' })
} else if (path.startsWith('/constructeurs')) {
result.push({ label: 'Fournisseurs', path: '/constructeurs' })
} else if (path.startsWith('/activity-log')) {
result.push({ label: 'Journal d\'activité', path: '/activity-log' })
} else if (path.startsWith('/admin')) {
result.push({ label: 'Administration', path: '/admin' })
} else if (path.startsWith('/documents')) {
result.push({ label: 'Documents', path: '/documents' })
} else if (path.startsWith('/comments')) {
result.push({ label: 'Commentaires', path: '/comments' })
}
// Category pages
else if (path.startsWith('/component-category')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Catégorie', path })
} else if (path.startsWith('/piece-category')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Catégorie', path })
} else if (path.startsWith('/product-category')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Catégorie', path })
}
// Create pages
else if (path.startsWith('/pieces/create')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Nouvelle pièce', path })
} else if (path.startsWith('/component/create')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Nouveau composant', path })
} else if (path.startsWith('/product/create')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Nouveau produit', path })
} else if (path === '/machines/new') {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Nouvelle machine', path })
}
return result
})
</script>

View File

@@ -44,7 +44,7 @@
<!-- Mobile: dropdown groups --> <!-- Mobile: dropdown groups -->
<li <li
v-for="group in navGroups" v-for="group in visibleGroups"
:key="group.id + '-mobile'" :key="group.id + '-mobile'"
class="mt-1 border-t border-base-200 pt-2" class="mt-1 border-t border-base-200 pt-2"
> >
@@ -122,7 +122,7 @@
<!-- Desktop: dropdown groups --> <!-- Desktop: dropdown groups -->
<li <li
v-for="group in navGroups" v-for="group in visibleGroups"
:key="group.id + '-desktop'" :key="group.id + '-desktop'"
class="relative" class="relative"
@mouseenter="setDropdown(group.id + '-desktop')" @mouseenter="setDropdown(group.id + '-desktop')"
@@ -270,11 +270,9 @@ import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideLogOut from '~icons/lucide/log-out' import IconLucideLogOut from '~icons/lucide/log-out'
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard' import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import IconLucideFactory from '~icons/lucide/factory' import IconLucideFactory from '~icons/lucide/factory'
import IconLucideBookOpen from '~icons/lucide/book-open'
import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePuzzle from '~icons/lucide/puzzle'
import IconLucidePackage from '~icons/lucide/package' import IconLucidePackage from '~icons/lucide/package'
import IconLucideLink from '~icons/lucide/link'
import IconLucideSun from '~icons/lucide/sun' import IconLucideSun from '~icons/lucide/sun'
import IconLucideMoon from '~icons/lucide/moon' import IconLucideMoon from '~icons/lucide/moon'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png' import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
@@ -296,55 +294,40 @@ interface NavGroup {
icon?: Component icon?: Component
activePaths: string[] activePaths: string[]
children: NavLink[] children: NavLink[]
requiresEdit?: boolean
} }
const simpleLinks: NavLink[] = [ const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard }, { to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory }, { to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
{ to: '/doc', label: 'Documentation', icon: IconLucideBookOpen },
] ]
const navGroups: NavGroup[] = [ const navGroups: NavGroup[] = [
{ {
id: 'component', id: 'catalogues',
label: 'Composants', label: 'Catalogues',
icon: IconLucideCpu,
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{
id: 'pieces',
label: 'Pièces',
icon: IconLucidePuzzle,
activePaths: ['/piece-category', '/pieces-catalog'],
children: [
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
{ to: '/piece-category', label: 'Catégorie de pièce' },
],
},
{
id: 'products',
label: 'Produits',
icon: IconLucidePackage, icon: IconLucidePackage,
activePaths: ['/product-category', '/product-catalog'], activePaths: ['/catalogues', '/component', '/piece', '/product'],
children: [ children: [
{ to: '/product-catalog', label: 'Catalogue des produits' }, { to: '/catalogues/composants', label: 'Composants' },
{ to: '/product-category', label: 'Catégorie de produit' }, { to: '/catalogues/pieces', label: 'Pièces' },
{ to: '/catalogues/produits', label: 'Produits' },
], ],
}, },
{ {
id: 'resources', id: 'admin',
label: 'Ressources liées', label: 'Administration',
icon: IconLucideLink, icon: IconLucideSettings,
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'], activePaths: ['/sites', '/constructeurs', '/activity-log', '/admin', '/documents', '/comments', '/component-category', '/piece-category', '/product-category'],
requiresEdit: true,
children: [ children: [
{ to: '/sites', label: 'Sites' }, { to: '/sites', label: 'Sites' },
{ to: '/documents', label: 'Documents' }, { to: '/documents', label: 'Documents' },
{ to: '/constructeurs', label: 'Fournisseurs' }, { to: '/constructeurs', label: 'Fournisseurs' },
{ to: '/comments', label: 'Commentaires' }, { to: '/comments', label: 'Commentaires' },
{ to: '/activity-log', label: 'Journal d\'activité' }, { to: '/activity-log', label: 'Journal d\'activité' },
{ to: '/admin', label: 'Profils' },
], ],
}, },
] ]
@@ -353,6 +336,10 @@ const route = useRoute()
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown() const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
const { activeProfile } = useProfileSession() const { activeProfile } = useProfileSession()
const { isAdmin, canEdit } = usePermissions() const { isAdmin, canEdit } = usePermissions()
const visibleGroups = computed(() =>
navGroups.filter(g => !g.requiresEdit || canEdit.value)
)
const { fetchUnresolvedCount } = useComments() const { fetchUnresolvedCount } = useComments()
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode() const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()

View File

@@ -49,6 +49,12 @@
/> />
</div> </div>
<div v-if="selectedTypeName && !selectedEntityId && !loadingEntities" class="bg-warning/10 border border-warning rounded-lg p-3 mb-4">
<p class="text-sm text-warning font-medium">
Aucun item sélectionné — la catégorie sera ajoutée avec le statut "À remplir".
</p>
</div>
<!-- Summary of selection --> <!-- Summary of selection -->
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4"> <div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p> <p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
@@ -64,10 +70,10 @@
<button <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
:disabled="!selectedEntityId" :disabled="!selectedTypeId"
@click="handleConfirm" @click="handleConfirm"
> >
Ajouter {{ selectedEntityId ? 'Ajouter' : 'Ajouter (catégorie seule)' }}
</button> </button>
</div> </div>
</div> </div>
@@ -90,11 +96,12 @@ type EntityKind = 'component' | 'piece' | 'product'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
entityKind: EntityKind entityKind: EntityKind
prefillTypeId?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
confirm: [entityId: string] confirm: [payload: { entityId?: string; modelTypeId: string; modelTypeName: string }]
}>() }>()
const selectedTypeId = ref('') const selectedTypeId = ref('')
@@ -166,6 +173,10 @@ watch(() => props.open, async (isOpen) => {
if (props.entityKind === 'component') await loadComponentTypes() if (props.entityKind === 'component') await loadComponentTypes()
else if (props.entityKind === 'piece') await loadPieceTypes() else if (props.entityKind === 'piece') await loadPieceTypes()
else await loadProductTypes() else await loadProductTypes()
if (props.prefillTypeId) {
selectedTypeId.value = props.prefillTypeId
}
}) })
// Load entities when type changes // Load entities when type changes
@@ -222,8 +233,12 @@ const handleClose = () => {
} }
const handleConfirm = () => { const handleConfirm = () => {
if (!selectedEntityId.value) return if (!selectedTypeId.value) return
emit('confirm', selectedEntityId.value) emit('confirm', {
entityId: selectedEntityId.value || undefined,
modelTypeId: selectedTypeId.value,
modelTypeName: selectedTypeName.value,
})
resetState() resetState()
emit('close') emit('close')
} }

View File

@@ -2,7 +2,10 @@
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2> <h2 class="card-title">
Composants
<span v-if="components.length" class="badge badge-outline badge-sm ml-1">{{ components.length }}</span>
</h2>
<button <button
type="button" type="button"
class="btn btn-ghost btn-sm gap-2" class="btn btn-ghost btn-sm gap-2"
@@ -28,12 +31,15 @@
<div v-for="component in components" :key="component.id"> <div v-for="component in components" :key="component.id">
<ComponentHierarchy <ComponentHierarchy
:components="[component]" :components="[component]"
:is-edit-mode="false" :is-edit-mode="isEditMode"
:show-delete="isEditMode" :show-delete="isEditMode"
:collapse-all="collapsed" :collapse-all="collapsed"
:toggle-token="collapseToggleToken" :toggle-token="collapseToggleToken"
@update="$emit('update-component', $event)"
@edit-piece="$emit('edit-piece', $event)" @edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('remove-component', component.linkId || component.id)" @delete="$emit('remove-component', component.linkId || component.id)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/> />
</div> </div>
</div> </div>
@@ -68,5 +74,6 @@ defineEmits<{
'custom-field-update': [fieldUpdate: any] 'custom-field-update': [fieldUpdate: any]
'add-component': [] 'add-component': []
'remove-component': [linkId: string] 'remove-component': [linkId: string]
'fill-entity': [linkId: string, modelTypeId: string]
}>() }>()
</script> </script>

View File

@@ -25,7 +25,7 @@
<span class="label-text text-sm">{{ field.name }}</span> <span class="label-text text-sm">{{ field.name }}</span>
</label> </label>
<div class="input input-bordered input-sm bg-base-200"> <div class="input input-bordered input-sm bg-base-200">
{{ formatCustomFieldValue(field) }} {{ formatValueForDisplay(field) }}
</div> </div>
</div> </div>
</div> </div>
@@ -180,7 +180,7 @@
<script setup lang="ts"> <script setup lang="ts">
import IconLucideTrash from '~icons/lucide/trash' import IconLucideTrash from '~icons/lucide/trash'
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils' import { formatValueForDisplay } from '~/shared/utils/customFields'
defineProps<{ defineProps<{
customFields: any[] customFields: any[]

View File

@@ -1,40 +1,46 @@
<template> <template>
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div class="space-y-3">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<h1 class="text-3xl font-bold"> <div class="flex flex-col gap-1">
{{ title }} <div class="flex items-center gap-3 flex-wrap">
</h1> <h1 class="text-2xl font-bold">{{ title }}</h1>
</div> <div
<div class="flex items-center gap-2 print:hidden" data-print-hide> v-if="siteName"
<button class="badge badge-outline font-semibold"
@click="$emit('toggle-edit')" :style="siteStyle"
class="btn btn-primary" >
:class="{ 'btn-outline': isEditMode }" {{ siteName }}
> </div>
<IconLucideSquarePen <div v-if="reference" class="badge badge-outline">{{ reference }}</div>
</div>
<p v-if="description" class="text-sm text-base-content/60">{{ description }}</p>
</div>
<div class="flex items-center gap-2 print:hidden">
<button
v-if="canEdit"
type="button"
class="btn btn-primary btn-sm md:btn-md"
:class="{ 'btn-outline': isEditMode }"
@click="$emit('toggle-edit')"
>
<IconLucideSquarePen v-if="!isEditMode" class="w-4 h-4 mr-1" aria-hidden="true" />
<IconLucideEye v-else class="w-4 h-4 mr-1" aria-hidden="true" />
{{ isEditMode ? 'Voir d\u00e9tails' : 'Modifier' }}
</button>
<button
v-if="!isEditMode" v-if="!isEditMode"
class="w-5 h-5 mr-2" type="button"
aria-hidden="true" class="btn btn-ghost btn-sm md:btn-md"
/> title="Imprimer"
<IconLucideEye @click="$emit('open-print')"
v-else >
class="w-5 h-5 mr-2" <IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
aria-hidden="true" </button>
/> <NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
{{ isEditMode ? 'Voir détails' : 'Modifier' }} <IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
</button> Parc machines
<button </NuxtLink>
v-if="!isEditMode" </div>
@click="$emit('open-print')"
type="button"
class="btn btn-outline btn-secondary"
>
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
Imprimer
</button>
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
Retour aux machines
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -43,11 +49,16 @@
import IconLucideSquarePen from '~icons/lucide/square-pen' import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye' import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer' import IconLucidePrinter from '~icons/lucide/printer'
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const router = useRouter() const { canEdit } = usePermissions()
defineProps<{ const props = defineProps<{
title: string title: string
description?: string
siteName?: string
siteColor?: string
reference?: string
isEditMode: boolean isEditMode: boolean
}>() }>()
@@ -56,12 +67,12 @@ defineEmits<{
'open-print': [] 'open-print': []
}>() }>()
function goBack() { const siteStyle = computed(() => {
if (window.history.length > 1) { if (!props.siteColor) return {}
router.back() return {
borderColor: props.siteColor + '60',
backgroundColor: props.siteColor + '25',
color: props.siteColor,
} }
else { })
navigateTo('/machines')
}
}
</script> </script>

View File

@@ -3,7 +3,10 @@
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="card-title">Documents de la machine</h2> <h2 class="card-title">
Documents de la machine
<span v-if="documents.length" class="badge badge-outline badge-sm ml-1">{{ documents.length }}</span>
</h2>
<p class="text-xs text-gray-500">Ajoutez ou consultez les documents liés à cette machine.</p> <p class="text-xs text-gray-500">Ajoutez ou consultez les documents liés à cette machine.</p>
</div> </div>
<span v-if="isEditMode && files.length" class="badge badge-outline"> <span v-if="isEditMode && files.length" class="badge badge-outline">

View File

@@ -15,9 +15,9 @@
class="input input-bordered" class="input input-bordered"
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)" @input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
/> />
<div v-else class="input input-bordered bg-base-200"> <p v-else class="text-sm font-medium text-base-content py-1">
{{ machineName }} {{ machineName }}
</div> </p>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -38,9 +38,9 @@
{{ site.name }} {{ site.name }}
</option> </option>
</select> </select>
<div v-else class="input input-bordered bg-base-200"> <p v-else class="text-sm font-medium text-base-content py-1">
{{ machineSiteName || 'Non défini' }} {{ machineSiteName || 'Non défini' }}
</div> </p>
</div> </div>
<div v-if="isEditMode || machineReference" class="form-control"> <div v-if="isEditMode || machineReference" class="form-control">
<label class="label"> <label class="label">
@@ -54,9 +54,9 @@
class="input input-bordered" class="input input-bordered"
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)" @input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
/> />
<div v-else class="input input-bordered bg-base-200"> <p v-else class="text-sm font-medium text-base-content py-1">
{{ machineReference }} {{ machineReference }}
</div> </p>
</div> </div>
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2"> <div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
<label class="label"> <label class="label">
@@ -77,9 +77,9 @@
@update:model-value="$emit('update:constructeur-links', $event)" @update:model-value="$emit('update:constructeur-links', $event)"
@remove="$emit('remove-constructeur-link', $event)" @remove="$emit('remove-constructeur-link', $event)"
/> />
<div v-else-if="!isEditMode" class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center"> <p v-else-if="!isEditMode" class="text-sm font-medium text-base-content/50 py-1">
<span class="text-base-content/50">Non défini</span> Non défini
</div> </p>
</div> </div>
</div> </div>
@@ -152,9 +152,9 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="input input-bordered input-sm bg-base-200"> <p class="text-sm font-medium text-base-content py-1">
{{ formatCustomFieldValue(field) }} {{ formatValueForDisplay(field) }}
</div> </p>
</template> </template>
</div> </div>
</div> </div>
@@ -182,7 +182,7 @@ import { watch } from 'vue'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue' import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue' import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue' import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils' import { formatValueForDisplay } from '~/shared/utils/customFields'
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs' import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils' import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'

View File

@@ -2,7 +2,10 @@
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2> <h2 class="card-title">
Pièces de la machine
<span v-if="pieces.length" class="badge badge-outline badge-sm ml-1">{{ pieces.length }}</span>
</h2>
<button <button
type="button" type="button"
class="btn btn-ghost btn-sm gap-2" class="btn btn-ghost btn-sm gap-2"
@@ -34,7 +37,9 @@
:toggle-token="collapseToggleToken" :toggle-token="collapseToggleToken"
@update="$emit('update-piece', $event)" @update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)" @edit="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('remove-piece', piece.linkId || piece.id)" @delete="$emit('remove-piece', piece.linkId || piece.id)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/> />
</div> </div>
</div> </div>
@@ -65,7 +70,9 @@ defineEmits<{
'toggle-collapse': [] 'toggle-collapse': []
'update-piece': [piece: any] 'update-piece': [piece: any]
'edit-piece': [piece: any] 'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-piece': [] 'add-piece': []
'remove-piece': [linkId: string] 'remove-piece': [linkId: string]
'fill-entity': [linkId: string, modelTypeId: string]
}>() }>()
</script> </script>

View File

@@ -23,14 +23,33 @@
<div v-if="products.length" class="space-y-3"> <div v-if="products.length" class="space-y-3">
<div <div
v-for="product in products" v-for="product in products"
:key="product.id || product.name" :key="product.id || product.linkId || product.name"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2" class="rounded border p-3 text-sm space-y-2"
:class="product.pendingEntity ? 'border-error bg-error/10' : 'border-base-200 bg-base-200/60'"
> >
<div class="flex items-center justify-between flex-wrap gap-2"> <div class="flex items-center justify-between flex-wrap gap-2">
<p class="font-semibold text-base-content"> <p class="font-semibold" :class="product.pendingEntity ? 'text-error' : 'text-base-content'">
{{ product.name }} <NuxtLink
v-if="!isEditMode && !product.pendingEntity && product.id"
:to="machineId
? { path: `/product/${product.id}`, query: { from: 'machine', machineId } }
: `/product/${product.id}`"
class="hover:underline hover:text-primary transition-colors"
>
{{ product.name }}
</NuxtLink>
<span v-else>{{ product.name }}</span>
</p> </p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button
v-if="product.pendingEntity"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
title="Cliquer pour associer un item"
@click="$emit('fill-entity', (product.linkId || product.id) as string, product.modelTypeId as string)"
>
À remplir
</button>
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm"> <span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
{{ product.groupLabel }} {{ product.groupLabel }}
</span> </span>
@@ -123,7 +142,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
import { import {
@@ -132,6 +151,9 @@ import {
downloadDocument, downloadDocument,
} from '~/shared/utils/documentDisplayUtils' } from '~/shared/utils/documentDisplayUtils'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
defineProps<{ defineProps<{
products: Array<{ products: Array<{
id?: string | null id?: string | null
@@ -141,6 +163,9 @@ defineProps<{
supplierLabel?: string | null supplierLabel?: string | null
priceLabel?: string | null priceLabel?: string | null
groupLabel?: string groupLabel?: string
pendingEntity?: boolean
modelTypeId?: string | null
modelType?: string | null
documents?: Array<{ documents?: Array<{
id?: string id?: string
name?: string name?: string
@@ -156,6 +181,7 @@ defineProps<{
defineEmits<{ defineEmits<{
'add-product': [] 'add-product': []
'remove-product': [linkId: string] 'remove-product': [linkId: string]
'fill-entity': [linkId: string, modelTypeId: string]
}>() }>()
const previewDocument = ref<any>(null) const previewDocument = ref<any>(null)

View File

@@ -2,12 +2,14 @@
<main <main
class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8" class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8"
> >
<header class="space-y-2"> <template v-if="!hideHeading">
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1> <header class="space-y-2">
<p class="text-base text-base-content/70"> <h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
{{ descriptionText }} <p class="text-base text-base-content/70">
</p> {{ descriptionText }}
</header> </p>
</header>
</template>
<nav <nav
v-if="allowCategorySwitch" v-if="allowCategorySwitch"
@@ -55,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 }">
@@ -76,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>
@@ -99,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"
@@ -119,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 {
@@ -133,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.'
@@ -144,9 +124,11 @@ const props = withDefaults(
heading: string heading: string
description?: string description?: string
allowCategorySwitch?: boolean allowCategorySwitch?: boolean
hideHeading?: boolean
}>(), }>(),
{ {
allowCategorySwitch: false, allowCategorySwitch: false,
hideHeading: false,
}, },
) )
@@ -195,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' },
@@ -335,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)
@@ -396,26 +370,6 @@ const openRelatedEdit = (entry: { id: string }) => {
}) })
} }
const conversionModalOpen = ref(false)
const conversionTarget = ref<ModelType | null>(null)
const openConversionModal = (item: ModelType) => {
conversionTarget.value = item
conversionModalOpen.value = true
}
const closeConversionModal = () => {
conversionModalOpen.value = false
}
const onConverted = () => {
conversionModalOpen.value = false
invalidateEntityTypeCache('PIECE')
invalidateEntityTypeCache('COMPONENT')
showSuccess('Catégorie convertie avec succès.')
doRefresh()
}
watch( 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

@@ -31,16 +31,28 @@
:key="entry.id" :key="entry.id"
class="px-2 py-1" class="px-2 py-1"
> >
<button <div
type="button" class="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-2 hover:bg-base-200"
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@click="onOpenEdit(entry)"
> >
<span class="font-medium text-base-content">{{ entry.name }}</span> <div class="flex min-w-0 flex-col gap-0.5">
<span v-if="entry.reference" class="text-xs text-base-content/60"> <NuxtLink
Référence: {{ entry.reference }} :to="itemDetailPath(entry)"
</span> class="font-medium hover:underline hover:text-primary transition-colors"
</button> @click="emit('close')"
>
{{ entry.name }}
</NuxtLink>
<span v-if="entry.reference" class="text-xs text-base-content/60">
Référence: {{ entry.reference }}
</span>
</div>
<div class="shrink-0">
<span v-if="entry.machineCount > 0" class="badge badge-ghost badge-sm">
{{ entry.machineCount }} machine{{ entry.machineCount > 1 ? 's' : '' }}
</span>
<span v-else class="text-xs text-base-content/30">Aucune machine</span>
</div>
</div>
</li> </li>
</ul> </ul>
</div> </div>
@@ -57,14 +69,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { humanizeError } from '~/shared/utils/errorMessages'
import type { ModelCategory, ModelType } from '~/services/modelTypes' import type { ModelCategory, ModelType } from '~/services/modelTypes'
type RelatedEntry = { type RelatedEntry = {
id: string id: string
name: string name: string
reference?: string | null reference?: string | null
machineCount: number
} }
const props = defineProps<{ const props = defineProps<{
@@ -104,73 +115,37 @@ const modalSubtitle = computed(() => {
return `${count} ${labels.plural} liés.` return `${count} ${labels.plural} liés.`
}) })
const resolveRelatedConfig = (category: ModelCategory) => { const itemDetailPath = (item: RelatedEntry) => {
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' } if (!props.modelType) return '#'
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' } const category = props.modelType.category
return { endpoint: '/products', filterKey: 'typeProduct' } if (category === 'COMPONENT') return `/component/${item.id}`
} if (category === 'PIECE') return `/piece/${item.id}`
return `/product/${item.id}`
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
if (!item || typeof item !== 'object') return null
const record = item as Record<string, unknown>
if (typeof record.id !== 'string') return null
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
const reference
= typeof record.reference === 'string' && record.reference.trim()
? record.reference
: typeof record.code === 'string' && record.code.trim()
? record.code
: null
return { id: record.id, name, reference }
} }
const loadRelatedItems = async (modelType: ModelType) => { const loadRelatedItems = async (modelType: ModelType) => {
const { endpoint, filterKey } = resolveRelatedConfig(modelType.category)
const params = new URLSearchParams()
params.set('itemsPerPage', '200')
params.set(filterKey, `/api/model_types/${modelType.id}`)
params.set('order[name]', 'asc')
loading.value = true loading.value = true
error.value = null error.value = null
items.value = [] items.value = []
try { try {
const result = await get(`${endpoint}?${params.toString()}`) const result = await get(`/model_types/${modelType.id}/related-items`)
if (!result.success) { if (!result.success) {
error.value = result.error ?? 'Impossible de charger les éléments liés.' error.value = result.error ?? 'Impossible de charger les éléments liés.'
return return
} }
const collection = extractCollection(result.data) if (Array.isArray(result.data)) {
items.value = collection items.value = result.data as RelatedEntry[]
.map(mapRelatedEntry)
.filter((entry): entry is RelatedEntry => Boolean(entry))
}
catch (err) {
let raw: string | null = null
if (err && typeof err === 'object') {
const e = err as { data?: Record<string, unknown>, statusMessage?: string, message?: string }
if (e.data) {
const data = e.data
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
else if (typeof data.detail === 'string') raw = data.detail
else if (typeof data.message === 'string') raw = data.message
else if (typeof data.error === 'string') raw = data.error
}
if (!raw && typeof e.statusMessage === 'string') raw = e.statusMessage
if (!raw && typeof e.message === 'string') raw = e.message
} }
error.value = humanizeError(raw) }
catch {
error.value = 'Impossible de charger les éléments liés.'
} }
finally { finally {
loading.value = false loading.value = false
} }
} }
const onOpenEdit = (entry: RelatedEntry) => {
emit('open-edit', entry)
}
watch( watch(
() => props.open, () => props.open,
(isOpen) => { (isOpen) => {

View File

@@ -11,13 +11,14 @@
<h3 class="card-title text-lg text-base-content"> <h3 class="card-title text-lg text-base-content">
{{ site.name }} {{ site.name }}
</h3> </h3>
<div <NuxtLink
class="badge font-bold" :to="`/machines?sites=${site.id}`"
class="badge font-bold hover:opacity-80 transition-opacity"
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}" :style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
:class="!site.color ? 'badge-primary' : ''" :class="!site.color ? 'badge-primary' : ''"
> >
{{ machineCount }} machines {{ machineCount }} machines
</div> </NuxtLink>
</div> </div>
<div class="space-y-3 text-sm"> <div class="space-y-3 text-sm">
@@ -39,10 +40,10 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-2 text-base-content/60"> <NuxtLink :to="`/machines?sites=${site.id}`" class="flex items-center gap-2 text-base-content/60 hover:text-primary transition-colors">
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" /> <IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
<span>{{ machineCount }} machine(s)</span> <span>{{ machineCount }} machine(s)</span>
</div> </NuxtLink>
</div> </div>
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">

View File

@@ -17,15 +17,9 @@ import { useProductTypes } from '~/composables/useProductTypes'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
type CustomFieldInput,
normalizeCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks' import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils' import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils' import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
@@ -40,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'
@@ -77,7 +70,6 @@ export function useComponentCreate() {
loading: productsLoading, loading: productsLoading,
} = useProducts() } = useProducts()
const toast = useToast() const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments() const { uploadDocuments } = useDocuments()
const { syncLinks } = useConstructeurLinks() const { syncLinks } = useConstructeurLinks()
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
@@ -98,7 +90,8 @@ export function useComponentCreate() {
const constructeurLinks = ref<ConstructeurLinkEntry[]>([]) const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value)) const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const lastSuggestedName = ref('') const lastSuggestedName = ref('')
const customFieldInputs = ref<CustomFieldInput[]>([]) const createdComponentId = ref<string | null>(null)
const structureAssignments = ref<StructureAssignmentNode | null>(null) const structureAssignments = ref<StructureAssignmentNode | null>(null)
const selectedDocuments = ref<File[]>([]) const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false) const uploadingDocuments = ref(false)
@@ -148,26 +141,24 @@ export function useComponentCreate() {
return structure ? normalizeStructureForEditor(structure) : null return structure ? normalizeStructureForEditor(structure) : null
}) })
const {
fields: customFieldInputs,
requiredFilled: requiredCustomFieldsFilled,
saveAll: saveAllCustomFields,
refresh: refreshCustomFieldInputs,
} = useCustomFieldInputs({
definitions: computed(() => selectedTypeStructure.value?.customFields ?? []),
values: computed(() => []),
entityType: 'composant',
entityId: createdComponentId,
context: 'standalone',
})
const structureHasRequirements = computed(() => 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 requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() => Boolean( const canSubmit = computed(() => Boolean(
canEdit.value canEdit.value
@@ -225,7 +216,6 @@ export function useComponentCreate() {
watch(selectedType, (type) => { watch(selectedType, (type) => {
if (!type) { if (!type) {
clearCreationForm() clearCreationForm()
customFieldInputs.value = []
structureAssignments.value = null structureAssignments.value = null
return return
} }
@@ -233,7 +223,8 @@ export function useComponentCreate() {
creationForm.name = type.name creationForm.name = type.name
} }
lastSuggestedName.value = creationForm.name lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(selectedTypeStructure.value) // useCustomFieldInputs auto-refreshes via its watcher on definitions
refreshCustomFieldInputs()
structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value) structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value)
}) })
@@ -305,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
@@ -323,12 +309,11 @@ export function useComponentCreate() {
const result = await createComposant(payload) const result = await createComposant(payload)
if (result.success) { if (result.success) {
const createdComponent = result.data as Record<string, any> const createdComponent = result.data as Record<string, any>
await _saveCustomFieldValues( createdComponentId.value = createdComponent.id
'composant', const failedFields = await saveAllCustomFields()
createdComponent.id, if (failedFields.length) {
[createdComponent?.typeComposant?.structure?.customFields], toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast }, }
)
if (selectedDocuments.value.length && result.data?.id) { if (selectedDocuments.value.length && result.data?.id) {
uploadingDocuments.value = true uploadingDocuments.value = true
const uploadResult = await uploadDocuments( const uploadResult = await uploadDocuments(
@@ -413,6 +398,7 @@ export function useComponentCreate() {
structureSelectionsComplete, structureSelectionsComplete,
canEdit, canEdit,
canSubmit, canSubmit,
requiredCustomFieldsFilled,
// Functions // Functions
typeOptionLabel, typeOptionLabel,

View File

@@ -6,14 +6,13 @@ import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProductTypes } from '~/composables/useProductTypes' import { useProductTypes } from '~/composables/useProductTypes'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks' import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useComponentHistory } from '~/composables/useComponentHistory' import { useEntityHistory } from '~/composables/useEntityHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils' import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils' import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
@@ -29,12 +28,7 @@ import {
import type { ComponentModelStructure } from '~/shared/types/inventory' import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
import { import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils' import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils'
interface ComponentCatalogType extends ModelType { interface ComponentCatalogType extends ModelType {
@@ -64,7 +58,6 @@ export function useComponentEdit(componentId: string) {
const { products } = useProducts() const { products } = useProducts()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks() const { fetchLinks, syncLinks } = useConstructeurLinks()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast() const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments() const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
const { const {
@@ -72,7 +65,7 @@ export function useComponentEdit(componentId: string) {
loading: historyLoading, loading: historyLoading,
error: historyError, error: historyError,
loadHistory, loadHistory,
} = useComponentHistory() } = useEntityHistory('composant')
const component = ref<any | null>(null) const component = ref<any | null>(null)
const loading = ref(true) const loading = ref(true)
@@ -96,7 +89,6 @@ export function useComponentEdit(componentId: string) {
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([]) const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value)) const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const customFieldInputs = ref<CustomFieldInput[]>([])
const fetchedPieceTypeMap = ref<Record<string, string>>({}) const fetchedPieceTypeMap = ref<Record<string, string>>({})
const pieceTypeLabelMap = computed(() => const pieceTypeLabelMap = computed(() =>
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value), buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
@@ -207,18 +199,23 @@ export function useComponentEdit(componentId: string) {
return structure ? normalizeStructureForEditor(structure) : null return structure ? normalizeStructureForEditor(structure) : null
}) })
const refreshCustomFieldInputs = ( const {
structureOverride?: ComponentModelStructure | null, fields: customFieldInputs,
valuesOverride?: any[] | null, requiredFilled: requiredCustomFieldsFilled,
) => { saveAll: saveAllCustomFields,
const structure = structureOverride ?? selectedTypeStructure.value ?? null refresh: refreshCustomFieldInputs,
const values = valuesOverride ?? component.value?.customFieldValues ?? null } = useCustomFieldInputs({
customFieldInputs.value = buildCustomFieldInputs(structure, values) definitions: computed(() => selectedTypeStructure.value?.customFields ?? []),
} values: computed(() => component.value?.customFieldValues ?? []),
entityType: 'composant',
const requiredCustomFieldsFilled = computed(() => entityId: computed(() => component.value?.id ?? null),
_requiredCustomFieldsFilled(customFieldInputs.value), context: 'standalone',
) onValueCreated: (newValue) => {
if (component.value && Array.isArray(component.value.customFieldValues)) {
component.value.customFieldValues.push(newValue)
}
},
})
const canSubmit = computed(() => Boolean( const canSubmit = computed(() => Boolean(
canEdit.value canEdit.value
@@ -239,8 +236,7 @@ export function useComponentEdit(componentId: string) {
component.value = result.data component.value = result.data
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : [] // The watcher on useCustomFieldInputs will auto-refresh when component.value changes
refreshCustomFieldInputs(undefined, customValues)
loadHistory(result.data.id).catch(() => {}) loadHistory(result.data.id).catch(() => {})
} }
@@ -288,14 +284,16 @@ export function useComponentEdit(componentId: string) {
if (!structure?.pieces) return [] if (!structure?.pieces) return []
return (structure.pieces as any[]).map((slot: any, i: number) => { return (structure.pieces as any[]).map((slot: any, i: number) => {
const edits = slotEdits.pieces[slot.slotId] const edits = slotEdits.pieces[slot.slotId]
const selectedPieceId = edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null)
return { return {
slotId: slot.slotId, slotId: slot.slotId,
typePieceId: slot.typePieceId, typePieceId: slot.typePieceId,
selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null), selectedPieceId,
selectedPieceName: slot.selectedPieceName ?? null, selectedPieceName: slot.selectedPieceName ?? null,
quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1), quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
position: slot.position ?? i, position: slot.position ?? i,
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`, label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
isEmpty: !selectedPieceId,
} }
}) })
}) })
@@ -305,14 +303,16 @@ export function useComponentEdit(componentId: string) {
if (!structure?.products) return [] if (!structure?.products) return []
return (structure.products as any[]).map((slot: any, i: number) => { return (structure.products as any[]).map((slot: any, i: number) => {
const edits = slotEdits.products[slot.slotId] const edits = slotEdits.products[slot.slotId]
const selectedProductId = edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null)
return { return {
slotId: slot.slotId, slotId: slot.slotId,
typeProductId: slot.typeProductId, typeProductId: slot.typeProductId,
selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null), selectedProductId,
selectedProductName: slot.selectedProductName ?? null, selectedProductName: slot.selectedProductName ?? null,
familyCode: slot.familyCode, familyCode: slot.familyCode,
position: slot.position ?? i, position: slot.position ?? i,
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`, label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
isEmpty: !selectedProductId,
} }
}) })
}) })
@@ -322,15 +322,17 @@ export function useComponentEdit(componentId: string) {
if (!structure?.subcomponents) return [] if (!structure?.subcomponents) return []
return (structure.subcomponents as any[]).map((slot: any, i: number) => { return (structure.subcomponents as any[]).map((slot: any, i: number) => {
const edits = slotEdits.subcomponents[slot.slotId] const edits = slotEdits.subcomponents[slot.slotId]
const selectedComponentId = edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null)
return { return {
slotId: slot.slotId, slotId: slot.slotId,
typeComposantId: slot.typeComposantId, typeComposantId: slot.typeComposantId,
selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null), selectedComponentId,
selectedComponentName: slot.selectedComponentName ?? null, selectedComponentName: slot.selectedComponentName ?? null,
alias: slot.alias, alias: slot.alias,
familyCode: slot.familyCode, familyCode: slot.familyCode,
position: slot.position ?? i, position: slot.position ?? i,
label: slot.alias || `Sous-composant #${i + 1}`, label: slot.alias || `Sous-composant #${i + 1}`,
isEmpty: !selectedComponentId,
} }
}) })
}) })
@@ -386,14 +388,10 @@ export function useComponentEdit(componentId: string) {
const result = await updateComposant(component.value.id, payload) const result = await updateComposant(component.value.id, payload)
if (result.success && result.data) { if (result.success && result.data) {
const updatedComponent = result.data as Record<string, any> const updatedComponent = result.data as Record<string, any>
await _saveCustomFieldValues( const failedFields = await saveAllCustomFields()
'composant', if (failedFields.length) {
updatedComponent.id, toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
[ }
updatedComponent?.typeComposant?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
// Save slot edits // Save slot edits
const slotPromises: Promise<any>[] = [] const slotPromises: Promise<any>[] = []
@@ -493,7 +491,7 @@ export function useComponentEdit(componentId: string) {
initialized.value = true initialized.value = true
} }
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues) // useCustomFieldInputs auto-refreshes via its watcher on definitions + values
}, },
{ immediate: true }, { immediate: true },
) )
@@ -559,6 +557,7 @@ export function useComponentEdit(componentId: string) {
originalConstructeurLinks, originalConstructeurLinks,
constructeurIdsFromForm, constructeurIdsFromForm,
customFieldInputs, customFieldInputs,
requiredCustomFieldsFilled,
historyFieldLabels, historyFieldLabels,
// Computed // Computed

View File

@@ -1,12 +0,0 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ComponentHistoryActor = EntityHistoryActor
export type ComponentHistoryEntry = EntityHistoryEntry
export function useComponentHistory() {
return useEntityHistory('composant')
}

View File

@@ -4,7 +4,7 @@ import { useApi } from './useApi'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Composant { export interface Composant {
id: string id: string
@@ -51,17 +51,6 @@ const total = ref(0)
const loading = ref(false) const loading = ref(false)
const loaded = ref(false) const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function useComposants() { export function useComposants() {
const { showSuccess } = useToast() const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()

View File

@@ -0,0 +1,205 @@
/**
* Unified reactive custom field management composable.
*
* Replaces: useEntityCustomFields.ts, custom field parts of useMachineDetailCustomFields.ts,
* and inline custom field logic in useComponentEdit/useComponentCreate/usePieceEdit.
*
* DESIGN NOTE: Uses an internal mutable `ref` (not a `computed`) so that
* save operations can update `customFieldValueId` in place without being
* overwritten on the next reactivity cycle. Call `refresh()` to re-merge
* from the source definitions + values (e.g. after fetching fresh data).
*/
import { ref, watch, computed, type MaybeRef, toValue } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import {
mergeDefinitionsWithValues,
filterByContext,
formatValueForSave,
shouldPersist,
requiredFieldsFilled,
type CustomFieldDefinition,
type CustomFieldValue,
type CustomFieldInput,
} from '~/shared/utils/customFields'
export type { CustomFieldDefinition, CustomFieldValue, CustomFieldInput }
export type CustomFieldEntityType =
| 'machine'
| 'composant'
| 'piece'
| 'product'
| 'machineComponentLink'
| 'machinePieceLink'
export interface UseCustomFieldInputsOptions {
/** Custom field definitions (from ModelType structure or machine.customFields) */
definitions: MaybeRef<any[]>
/** Persisted custom field values (from entity.customFieldValues or link.contextCustomFieldValues) */
values: MaybeRef<any[]>
/** Entity type for API upsert calls */
entityType: CustomFieldEntityType
/** Entity ID for API upsert calls */
entityId: MaybeRef<string | null>
/** Filter context: 'standalone' hides machineContextOnly, 'machine' shows only machineContextOnly */
context?: 'standalone' | 'machine'
/** Optional callback to update the source values array after a save (keeps parent reactive state in sync) */
onValueCreated?: (newValue: { id: string; value: string; customField: any }) => void
}
export function useCustomFieldInputs(options: UseCustomFieldInputsOptions) {
const { entityType, context } = options
const {
updateCustomFieldValue: updateApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Internal mutable state — NOT a computed, so save can mutate in place
const _allFields = ref<CustomFieldInput[]>([])
// Re-merge from source definitions + values
const refresh = () => {
const defs = toValue(options.definitions)
const vals = toValue(options.values)
_allFields.value = mergeDefinitionsWithValues(defs, vals)
}
// Auto-refresh when reactive sources change
watch(
() => [toValue(options.definitions), toValue(options.values)],
() => refresh(),
{ immediate: true, deep: true },
)
// Filtered by context (standalone vs machine)
const fields = computed<CustomFieldInput[]>(() => {
if (!context) return _allFields.value
return filterByContext(_allFields.value, context)
})
// Validation
const requiredFilled = computed(() => requiredFieldsFilled(fields.value))
// Build metadata for upsert when no customFieldId is available (legacy fallback)
const _buildMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
})
// Update a single field value
const update = async (field: CustomFieldInput): Promise<boolean> => {
const id = toValue(options.entityId)
if (!id) {
showError(`Impossible de sauvegarder le champ "${field.name}"`)
return false
}
const value = formatValueForSave(field)
// Update existing value
if (field.customFieldValueId) {
const result: any = await updateApi(field.customFieldValueId, { value })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour`)
return true
}
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
return false
}
// Create new value via upsert — with metadata fallback when no ID
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
const result: any = await upsertCustomFieldValue(
field.customFieldId,
entityType,
id,
value,
metadata,
)
if (result.success) {
// Mutate in place (safe — _allFields is a ref, not computed)
if (result.data?.id) {
field.customFieldValueId = result.data.id
}
if (result.data?.customField?.id) {
field.customFieldId = result.data.customField.id
}
// Notify parent to update its reactive source
if (options.onValueCreated && result.data) {
options.onValueCreated(result.data)
}
showSuccess(`Champ "${field.name}" enregistré`)
return true
}
showError(`Erreur lors de l'enregistrement du champ "${field.name}"`)
return false
}
// Save all fields that have values
const saveAll = async (): Promise<string[]> => {
const id = toValue(options.entityId)
if (!id) return ['(entity ID missing)']
const failed: string[] = []
for (const field of fields.value) {
if (!shouldPersist(field)) continue
const value = formatValueForSave(field)
if (field.customFieldValueId) {
const result: any = await updateApi(field.customFieldValueId, { value })
if (!result.success) failed.push(field.name)
continue
}
// Upsert with metadata fallback when no customFieldId
const metadata = field.customFieldId ? undefined : _buildMetadata(field)
const result: any = await upsertCustomFieldValue(
field.customFieldId,
entityType,
id,
value,
metadata,
)
if (result.success) {
if (result.data?.id) {
field.customFieldValueId = result.data.id
}
if (result.data?.customField?.id) {
field.customFieldId = result.data.customField.id
}
if (options.onValueCreated && result.data) {
options.onValueCreated(result.data)
}
} else {
failed.push(field.name)
}
}
return failed
}
return {
/** All merged fields filtered by context */
fields,
/** All merged fields (unfiltered) */
allFields: _allFields,
/** Whether all required fields have values */
requiredFilled,
/** Update a single field value via API */
update,
/** Save all fields with values, returns list of failed field names */
saveAll,
/** Re-merge from source definitions + values (call after fetching fresh data) */
refresh,
}
}

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from './useApi' import { useApi } from './useApi'
import { useToast } from './useToast' import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Document { export interface Document {
id: string id: string
@@ -58,13 +58,6 @@ const total = ref(0)
const loading = ref(false) const loading = ref(false)
const loaded = ref(false) const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') return p.totalItems
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
return fallbackLength
}
export function useDocuments() { export function useDocuments() {
const { get, patch, postFormData, delete: del } = useApi() const { get, patch, postFormData, delete: del } = useApi()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()

View File

@@ -1,181 +0,0 @@
/**
* Reactive custom field management for entity items (ComponentItem, PieceItem).
*
* Wraps the pure logic from entityCustomFieldLogic.ts with Vue reactivity,
* watchers, and API calls for updating/upserting custom field values.
*/
import { computed, watch } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import {
buildDefinitionSources,
buildCandidateCustomFields,
mergeFieldDefinitionsWithValues,
dedupeMergedFields,
ensureCustomFieldId,
resolveFieldId,
resolveFieldName,
resolveFieldType,
resolveFieldReadOnly,
resolveCustomFieldId,
buildCustomFieldMetadata,
} from '~/shared/utils/entityCustomFieldLogic'
export interface EntityCustomFieldsDeps {
entity: () => any
entityType: 'composant' | 'piece'
}
export function useEntityCustomFields(deps: EntityCustomFieldsDeps) {
const { entity, entityType } = deps
const {
updateCustomFieldValue: updateCustomFieldValueApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
const definitionSources = computed(() =>
buildDefinitionSources(entity(), entityType),
)
const displayedCustomFields = computed(() =>
dedupeMergedFields(
mergeFieldDefinitionsWithValues(
definitionSources.value,
entity().customFieldValues,
),
),
)
const candidateCustomFields = computed(() =>
buildCandidateCustomFields(entity(), definitionSources.value),
)
// Watchers to ensure field IDs are resolved
watch(
candidateCustomFields,
() => {
const candidates = candidateCustomFields.value
;(displayedCustomFields.value || []).forEach((field: any) => {
if (field) ensureCustomFieldId(field, candidates)
})
},
{ immediate: true, deep: true },
)
watch(
displayedCustomFields,
(fields) => {
const candidates = candidateCustomFields.value
;(fields || []).forEach((field: any) => {
if (field) ensureCustomFieldId(field, candidates)
})
},
{ immediate: true, deep: true },
)
const updateCustomField = async (field: any) => {
if (!field || resolveFieldReadOnly(field)) return
const e = entity()
const fieldValueId = resolveFieldId(field)
// Update existing field value
if (fieldValueId) {
const result: any = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
const existingValue = e.customFieldValues?.find((v: any) => v.id === fieldValueId)
if (existingValue?.customField?.id) {
field.customFieldId = existingValue.customField.id
field.customField = existingValue.customField
}
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
}
return
}
// Create new field value
const customFieldId = ensureCustomFieldId(field, candidateCustomFields.value)
const fieldName = resolveFieldName(field)
if (!e?.id) {
showError(`Impossible de créer la valeur pour ce champ`)
return
}
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
showError(`Impossible de créer la valeur pour ce champ`)
return
}
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
const result: any = await upsertCustomFieldValue(
customFieldId,
entityType,
e.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
field.customField = newValue.customField
}
if (Array.isArray(e.customFieldValues)) {
const index = e.customFieldValues.findIndex((v: any) => v.id === newValue.id)
if (index !== -1) {
e.customFieldValues.splice(index, 1, newValue)
} else {
e.customFieldValues.push(newValue)
}
} else {
e.customFieldValues = [newValue]
}
}
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
// Update definitions list
const definitions = Array.isArray(e.customFields) ? [...e.customFields] : []
const fieldIdentifier = ensureCustomFieldId(field, candidateCustomFields.value)
const existingIndex = definitions.findIndex((definition: any) => {
const definitionId = resolveCustomFieldId(definition)
if (fieldIdentifier && definitionId) return definitionId === fieldIdentifier
return definition?.name === resolveFieldName(field)
})
const updatedDefinition = {
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
customFieldValueId: field.customFieldValueId,
customFieldId: fieldIdentifier,
name: resolveFieldName(field),
type: resolveFieldType(field),
required: field.required ?? false,
options: field.options ?? [],
value: field.value ?? '',
customField: field.customField ?? null,
}
if (existingIndex !== -1) {
definitions.splice(existingIndex, 1, updatedDefinition)
} else {
definitions.push(updatedDefinition)
}
e.customFields = definitions
} else {
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
}
}
return {
displayedCustomFields,
candidateCustomFields,
updateCustomField,
}
}

View File

@@ -8,14 +8,12 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { normalizeStructureForEditor } from '~/shared/modelUtils'
import { import {
shouldDisplayCustomField, mergeDefinitionsWithValues,
normalizeExistingCustomFieldDefinitions, filterByContext,
normalizeCustomFieldValueEntry, hasDisplayableValue,
mergeCustomFieldValuesWithDefinitions, type CustomFieldInput,
dedupeCustomFieldEntries, } from '~/shared/utils/customFields'
} from '~/shared/utils/customFieldUtils'
import { import {
resolveConstructeurs, resolveConstructeurs,
uniqueConstructeurIds, uniqueConstructeurIds,
@@ -44,6 +42,7 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const machineCustomFields = ref<AnyRecord[]>([]) const machineCustomFields = ref<AnyRecord[]>([])
const pendingContextFieldUpdates = ref<AnyRecord[]>([])
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Computed // Computed
@@ -52,56 +51,23 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
const visibleMachineCustomFields = computed(() => { const visibleMachineCustomFields = computed(() => {
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : [] const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
if (isEditMode.value) return fields if (isEditMode.value) return fields
return fields.filter((field) => shouldDisplayCustomField(field)) return fields.filter((field) => hasDisplayableValue(field as unknown as CustomFieldInput))
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Transform helpers // Transform helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const getStructureCustomFields = (structure: unknown): AnyRecord[] => {
if (!structure || typeof structure !== 'object') return []
const normalized = normalizeStructureForEditor(structure as any) as any
return Array.isArray(normalized?.customFields)
? (normalized.customFields as AnyRecord[])
: []
}
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => { const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
return (piecesData || []).map((piece) => { return (piecesData || []).map((piece) => {
const typePiece = (piece.typePiece as AnyRecord) || {} const typePiece = (piece.typePiece as AnyRecord) || {}
const normalizeStructureDefs = (structure: unknown) => const customFields = filterByContext(
structure ? normalizeStructureForEditor(structure as AnyRecord) : null mergeDefinitionsWithValues(
typePiece.customFields ?? (piece.typePiece as AnyRecord)?.customFields ?? [],
const normalizedStructureDefs = [ piece.customFieldValues ?? [],
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
normalizeStructureDefs(typePiece.structure),
]
const valueEntries = [
...(Array.isArray(piece.customFieldValues) ? piece.customFieldValues : []),
...(Array.isArray(piece.customFields)
? (piece.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
...(Array.isArray(typePiece.customFieldValues)
? (typePiece.customFieldValues as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const customFields = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(piece.customFields),
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
), ),
'standalone',
) )
const constructeurIds = uniqueConstructeurIds( const constructeurIds = uniqueConstructeurIds(
@@ -140,7 +106,9 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
return { return {
...normalizedPiece, ...normalizedPiece,
customFields, customFields: customFields.filter((f: any) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: piece.contextCustomFields ?? [],
contextCustomFieldValues: piece.contextCustomFieldValues ?? [],
documents: piece.documents || [], documents: piece.documents || [],
constructeurs: constructeursList, constructeurs: constructeursList,
constructeur: constructeursList[0] || piece.constructeur || null, constructeur: constructeursList[0] || piece.constructeur || null,
@@ -156,43 +124,16 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
} }
const transformComponentCustomFields = (componentsData: AnyRecord[]): AnyRecord[] => { const transformComponentCustomFields = (componentsData: AnyRecord[]): AnyRecord[] => {
const normalizeStructureDefs = (structure: unknown) =>
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
return (componentsData || []).map((component) => { return (componentsData || []).map((component) => {
const type = (component.typeComposant as AnyRecord) || {} const type = (component.typeComposant as AnyRecord) || {}
const normalizedStructureDefs = [
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
normalizeStructureDefs(type.structure),
]
const actualComponent = (component.originalComposant as AnyRecord) || component const actualComponent = (component.originalComposant as AnyRecord) || component
const valueEntries = [ const customFields = filterByContext(
...(Array.isArray(component.customFieldValues) ? component.customFieldValues : []), mergeDefinitionsWithValues(
...(Array.isArray(component.customFields) type.customFields ?? [],
? (component.customFields as AnyRecord[]) component.customFieldValues ?? actualComponent?.customFieldValues ?? [],
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
...(Array.isArray(actualComponent?.customFields)
? (actualComponent.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const customFields = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(component.customFields),
normalizeExistingCustomFieldDefinitions((component.definition as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(type.customFields),
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
), ),
'standalone',
) )
const piecesTransformed = component.pieces const piecesTransformed = component.pieces
@@ -240,7 +181,9 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
return { return {
...normalizedComponent, ...normalizedComponent,
customFields, customFields: customFields.filter((f: any) => !f.machineContextOnly && !f.customField?.machineContextOnly),
contextCustomFields: component.contextCustomFields ?? [],
contextCustomFieldValues: component.contextCustomFieldValues ?? [],
pieces: piecesTransformed, pieces: piecesTransformed,
subComponents, subComponents,
documents: component.documents || [], documents: component.documents || [],
@@ -266,21 +209,11 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
machineCustomFields.value = [] machineCustomFields.value = []
return return
} }
const valueEntries = [ const merged = mergeDefinitionsWithValues(
...(Array.isArray(machine.value.customFieldValues) ? machine.value.customFieldValues : []), machine.value?.customFields ?? [],
...(Array.isArray(machine.value.customFields) machine.value?.customFieldValues ?? [],
? (machine.value.customFields as AnyRecord[]) )
.map(normalizeCustomFieldValueEntry) machineCustomFields.value = merged.map(f => ({ ...f, readOnly: false }))
.filter((e) => e !== null)
: []),
]
const merged = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
),
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
machineCustomFields.value = merged
} }
const setMachineCustomFieldValue = (field: AnyRecord, value: unknown) => { const setMachineCustomFieldValue = (field: AnyRecord, value: unknown) => {
@@ -297,7 +230,8 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
const updateMachineCustomField = async (field: AnyRecord) => { const updateMachineCustomField = async (field: AnyRecord) => {
if (!machine.value || !field) return if (!machine.value || !field) return
const { id: customFieldId, customFieldValueId } = field const customFieldId = (field.customFieldId ?? field.id) as string | undefined
const customFieldValueId = field.customFieldValueId as string | undefined
const fieldLabel = (field.name as string) || 'Champ personnalisé' const fieldLabel = (field.name as string) || 'Champ personnalisé'
try { try {
@@ -376,6 +310,83 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
} }
} }
const handleCustomFieldUpdate = async (fieldUpdate: AnyRecord) => {
if (fieldUpdate?.entityType && fieldUpdate?.entityId) {
queueContextFieldUpdate(fieldUpdate)
return
}
await updatePieceCustomField(fieldUpdate)
}
const queueContextFieldUpdate = (fieldUpdate: AnyRecord) => {
const entityType = fieldUpdate.entityType as string | undefined
const entityId = fieldUpdate.entityId as string | undefined
const fieldId = fieldUpdate.fieldId as string | undefined
const customFieldValueId = fieldUpdate.customFieldValueId as string | undefined
if (!entityType || !entityId || (!fieldId && !customFieldValueId)) return
const nextUpdate = {
entityType,
entityId,
fieldId,
customFieldValueId,
value: fieldUpdate.value ?? '',
fieldName: fieldUpdate.fieldName ?? 'Champ contextuel',
}
const existingIndex = pendingContextFieldUpdates.value.findIndex(
(item) =>
item.entityType === entityType &&
item.entityId === entityId &&
item.fieldId === fieldId &&
item.customFieldValueId === customFieldValueId,
)
if (existingIndex >= 0) {
pendingContextFieldUpdates.value[existingIndex] = nextUpdate
return
}
pendingContextFieldUpdates.value.push(nextUpdate)
}
const clearPendingContextFieldUpdates = () => {
pendingContextFieldUpdates.value = []
}
const saveAllContextCustomFields = async () => {
const updates = pendingContextFieldUpdates.value.slice()
if (!updates.length) return
try {
for (const update of updates) {
if (update.customFieldValueId) {
await updateCustomFieldValueApi(update.customFieldValueId as string, {
value: update.value ?? '',
} as any)
continue
}
if (!update.fieldId) {
continue
}
await upsertCustomFieldValue(
update.fieldId as string,
update.entityType as string,
update.entityId as string,
update.value ?? '',
)
}
clearPendingContextFieldUpdates()
} catch (error) {
console.error('Erreur lors de la sauvegarde batch des champs contextuels:', error)
throw error
}
}
const saveAllMachineCustomFields = async () => { const saveAllMachineCustomFields = async () => {
if (!machine.value) return if (!machine.value) return
@@ -385,7 +396,8 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
) )
for (const field of fieldsToSave) { for (const field of fieldsToSave) {
const { id: customFieldId, customFieldValueId } = field const customFieldId = (field.customFieldId ?? field.id) as string | undefined
const customFieldValueId = field.customFieldValueId as string | undefined
try { try {
if (customFieldValueId) { if (customFieldValueId) {
@@ -431,6 +443,7 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
return { return {
// State // State
machineCustomFields, machineCustomFields,
pendingContextFieldUpdates,
// Computed // Computed
visibleMachineCustomFields, visibleMachineCustomFields,
@@ -444,6 +457,10 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
setMachineCustomFieldValue, setMachineCustomFieldValue,
updateMachineCustomField, updateMachineCustomField,
updatePieceCustomField, updatePieceCustomField,
handleCustomFieldUpdate,
queueContextFieldUpdate,
clearPendingContextFieldUpdates,
saveAllMachineCustomFields, saveAllMachineCustomFields,
saveAllContextCustomFields,
} }
} }

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)
@@ -151,13 +150,18 @@ export function useMachineDetailData(machineId: string) {
const { const {
machineCustomFields, machineCustomFields,
visibleMachineCustomFields, visibleMachineCustomFields,
pendingContextFieldUpdates,
transformCustomFields, transformCustomFields,
transformComponentCustomFields, transformComponentCustomFields,
syncMachineCustomFields, syncMachineCustomFields,
setMachineCustomFieldValue, setMachineCustomFieldValue,
updateMachineCustomField, updateMachineCustomField,
updatePieceCustomField, updatePieceCustomField,
handleCustomFieldUpdate,
queueContextFieldUpdate,
clearPendingContextFieldUpdates,
saveAllMachineCustomFields, saveAllMachineCustomFields,
saveAllContextCustomFields,
} = useMachineDetailCustomFields({ } = useMachineDetailCustomFields({
machine, machine,
isEditMode, isEditMode,
@@ -193,6 +197,10 @@ export function useMachineDetailData(machineId: string) {
removePieceLink, removePieceLink,
addProductLink, addProductLink,
removeProductLink, removeProductLink,
addComponentLinkCategoryOnly,
addPieceLinkCategoryOnly,
addProductLinkCategoryOnly,
fillEntityLink,
} = hierarchy } = hierarchy
// Keep the product links proxy in sync with the hierarchy's machineProductLinks // Keep the product links proxy in sync with the hierarchy's machineProductLinks
@@ -218,22 +226,6 @@ export function useMachineDetailData(machineId: string) {
const componentTypeOptions = computed(() => componentTypes.value || []) const 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) {
@@ -297,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()
} }
@@ -329,10 +320,13 @@ export function useMachineDetailData(machineId: string) {
// 2. Save all custom field values // 2. Save all custom field values
await saveAllMachineCustomFields() await saveAllMachineCustomFields()
// 3. Reload machine data to get fresh state // 3. Save contextual custom field values queued from piece/component inputs
await saveAllContextCustomFields()
// 4. Reload machine data to get fresh state
await loadMachineData() await loadMachineData()
// 4. Exit edit mode // 5. Exit edit mode
isEditMode.value = false isEditMode.value = false
toast.showSuccess('Machine mise à jour avec succès') toast.showSuccess('Machine mise à jour avec succès')
} catch (error) { } catch (error) {
@@ -346,6 +340,7 @@ export function useMachineDetailData(machineId: string) {
const cancelEdition = () => { const cancelEdition = () => {
initMachineFields() initMachineFields()
syncMachineCustomFields() syncMachineCustomFields()
clearPendingContextFieldUpdates()
constructeurLinks.value = originalConstructeurLinks.value.map(l => ({ ...l })) constructeurLinks.value = originalConstructeurLinks.value.map(l => ({ ...l }))
machineConstructeurIds.value = constructeurIdsFromLinks(constructeurLinks.value) machineConstructeurIds.value = constructeurIdsFromLinks(constructeurLinks.value)
isEditMode.value = false isEditMode.value = false
@@ -419,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 || [])
@@ -434,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
} }
@@ -482,12 +473,12 @@ export function useMachineDetailData(machineId: string) {
// UI state // UI state
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded, machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
machineCustomFields, 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,
@@ -495,7 +486,8 @@ export function useMachineDetailData(machineId: string) {
findProductById, resolveProductReference, getProductDisplay, findProductById, resolveProductReference, getProductDisplay,
initMachineFields, getMachineFieldId, initMachineFields, getMachineFieldId,
syncMachineCustomFields, setMachineCustomFieldValue, syncMachineCustomFields, setMachineCustomFieldValue,
updateMachineCustomField, updatePieceCustomField, updateMachineCustomField, updatePieceCustomField, handleCustomFieldUpdate,
queueContextFieldUpdate, clearPendingContextFieldUpdates, saveAllContextCustomFields,
refreshMachineDocuments, handleMachineFilesAdded, removeMachineDocument, refreshMachineDocuments, handleMachineFilesAdded, removeMachineDocument,
openPreview, closePreview, openPreview, closePreview,
updateMachineInfo, updateComponent, updatePieceFromComponent, updateMachineInfo, updateComponent, updatePieceFromComponent,
@@ -511,6 +503,8 @@ export function useMachineDetailData(machineId: string) {
loadMachineData, loadInitialData, loadMachineData, loadInitialData,
addComponentLink, removeComponentLink, addPieceLink, removePieceLink, addComponentLink, removeComponentLink, addPieceLink, removePieceLink,
addProductLink, removeProductLink, reloadMachineStructure, addProductLink, removeProductLink, reloadMachineStructure,
addComponentLinkCategoryOnly, addPieceLinkCategoryOnly,
addProductLinkCategoryOnly, fillEntityLink,
// External // External
constructeurs, loadProducts, updateMachineStructure, toast, constructeurs, loadProducts, updateMachineStructure, toast,

View File

@@ -39,7 +39,7 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
syncMachineCustomFields, syncMachineCustomFields,
} = deps } = deps
const { get, post: apiPost, delete: apiDel } = useApi() const { get, post: apiPost, delete: apiDel, patch: apiPatch } = useApi()
const toast = useToast() const toast = useToast()
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -263,6 +263,69 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
return result return result
} }
const addComponentLinkCategoryOnly = async (modelTypeId: string) => {
const result: any = await apiPost('/machine_component_links', {
machine: `/api/machines/${machineId}`,
modelType: `/api/model_types/${modelTypeId}`,
})
if (result.success) {
toast.showSuccess('Catégorie ajoutée — item à remplir')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout')
}
}
const addPieceLinkCategoryOnly = async (modelTypeId: string) => {
const result: any = await apiPost('/machine_piece_links', {
machine: `/api/machines/${machineId}`,
modelType: `/api/model_types/${modelTypeId}`,
})
if (result.success) {
toast.showSuccess('Catégorie ajoutée — item à remplir')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout')
}
}
const addProductLinkCategoryOnly = async (modelTypeId: string) => {
const result: any = await apiPost('/machine_product_links', {
machine: `/api/machines/${machineId}`,
modelType: `/api/model_types/${modelTypeId}`,
})
if (result.success) {
toast.showSuccess('Catégorie ajoutée — item à remplir')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout')
}
}
const fillEntityLink = async (linkId: string, entityId: string, entityKind: string) => {
let endpoint = ''
let payload: Record<string, string> = {}
if (entityKind === 'component') {
endpoint = `/machine_component_links/${linkId}`
payload = { composant: `/api/composants/${entityId}` }
} else if (entityKind === 'piece') {
endpoint = `/machine_piece_links/${linkId}`
payload = { piece: `/api/pieces/${entityId}` }
} else {
endpoint = `/machine_product_links/${linkId}`
payload = { product: `/api/products/${entityId}` }
}
const result: any = await apiPatch(endpoint, payload)
if (result.success) {
toast.showSuccess('Item associé avec succès')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'association')
}
}
const removeProductLink = async (linkId: string) => { const removeProductLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_product_links/${linkId}`) const result: any = await apiDel(`/machine_product_links/${linkId}`)
if (result.success) { if (result.success) {
@@ -301,6 +364,10 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
addPieceLink, addPieceLink,
removePieceLink, removePieceLink,
addProductLink, addProductLink,
addComponentLinkCategoryOnly,
addPieceLinkCategoryOnly,
addProductLinkCategoryOnly,
fillEntityLink,
removeProductLink, removeProductLink,
} }
} }

View File

@@ -103,7 +103,7 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
return { return {
id: (resolved?.id as string) || productId || null, id: (resolved?.id as string) || productId || null,
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null, linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
name: (resolved?.name as string) || 'Produit inconnu', name: (resolved?.name as string) || (link.modelType as AnyRecord)?.name as string || 'Produit inconnu',
reference: (resolved?.reference as string) || null, reference: (resolved?.reference as string) || null,
supplierLabel: resolvedConstructeurs.length supplierLabel: resolvedConstructeurs.length
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null ? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
@@ -111,6 +111,9 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
priceLabel: resolved ? getProductPriceLabel(resolved) : null, priceLabel: resolved ? getProductPriceLabel(resolved) : null,
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '', groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [], documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
pendingEntity: (link.pendingEntity as boolean) || false,
modelTypeId: (link.modelTypeId as string) || null,
modelType: (link.modelType as string) || null,
} }
}) })
}) })

View File

@@ -73,11 +73,11 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const updateMachineInfo = async () => { const updateMachineInfo = async () => {
if (!machine.value) return if (!machine.value) return
try { try {
const result: any = await updateMachineApi(machine.value.id as string, { const payload: Record<string, unknown> = {}
name: machineName.value, if (machineName.value !== machine.value.name) payload.name = machineName.value
reference: machineReference.value, if (machineReference.value !== machine.value.reference) payload.reference = machineReference.value
siteId: machineSiteId.value || undefined, if ((machineSiteId.value || undefined) !== ((machine.value.siteId as string) || (machine.value.site as any)?.id || undefined)) payload.siteId = machineSiteId.value || undefined
} as any) const result: any = await updateMachineApi(machine.value.id as string, payload as any)
if (result.success) { if (result.success) {
const machinePayload = const machinePayload =
result.data?.machine && typeof result.data.machine === 'object' result.data?.machine && typeof result.data.machine === 'object'

View File

@@ -150,6 +150,30 @@ export const buildMachineHierarchyFromLinks = (
const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => { const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => {
if (!link || typeof link !== 'object') return null if (!link || typeof link !== 'object') return null
// Category-only link (no entity yet)
if (link.pendingEntity || (!link.piece && !link.pieceId)) {
const machinePieceLinkId = normalizePieceLinkId(link)
const mt = (link.modelType || null) as AnyRecord | null
return {
id: machinePieceLinkId || `pending-${link.id}`,
linkId: machinePieceLinkId,
name: mt?.name || 'Catégorie sans item',
reference: null,
prix: null,
pendingEntity: true,
modelTypeId: link.modelTypeId || mt?.id || null,
modelType: mt,
pieceId: null,
constructeurs: [],
documents: [],
customFields: [],
parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null,
parentComponentName,
machinePieceLinkId,
quantity: 1,
}
}
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
@@ -184,6 +208,8 @@ export const buildMachineHierarchyFromLinks = (
quantity: typeof link.quantity === 'number' ? link.quantity : 1, quantity: typeof link.quantity === 'number' ? link.quantity : 1,
definition: appliedPiece.definition || originalPiece?.definition || {}, definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [], customFields: appliedPiece.customFields || [],
contextCustomFields: link.contextCustomFields || [],
contextCustomFieldValues: link.contextCustomFieldValues || [],
} }
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id) const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
@@ -205,6 +231,35 @@ export const buildMachineHierarchyFromLinks = (
const createComponentNode = (link: AnyRecord): AnyRecord | null => { const createComponentNode = (link: AnyRecord): AnyRecord | null => {
if (!link || typeof link !== 'object') return null if (!link || typeof link !== 'object') return null
// Category-only link (no entity yet)
if (link.pendingEntity || (!link.composant && !link.composantId)) {
const machineComponentLinkId = normalizeComponentLinkId(link)
const mt = (link.modelType || null) as AnyRecord | null
return {
id: machineComponentLinkId || `pending-${link.id}`,
linkId: machineComponentLinkId,
name: mt?.name || 'Catégorie sans item',
reference: null,
prix: null,
pendingEntity: true,
modelTypeId: link.modelTypeId || mt?.id || null,
modelType: mt,
composantId: null,
composant: null,
constructeurs: [],
documents: [],
customFields: [],
customFieldValues: [],
subComponents: [],
pieces: [],
overrides: null,
parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null,
machineComponentLinkId,
childLinks: [],
pieceLinks: [],
}
}
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
@@ -227,11 +282,13 @@ export const buildMachineHierarchyFromLinks = (
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1) const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
const isEmpty = !resolved
const typePieceName = (resolved?.typePiece as AnyRecord)?.name || (definition.typePiece as AnyRecord)?.name || (def.typePiece as AnyRecord)?.name || null
return { return {
...(resolved || {}), ...(resolved || {}),
id: resolved?.id || `structure-piece-${composantId}-${index}`, id: resolved?.id || `structure-piece-${composantId}-${index}`,
pieceId: resolved?.id || null, pieceId: resolved?.id || null,
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`, name: resolved?.name || definition.role || definition.name || def.role || def.name || (typePieceName ? `${typePieceName}` : `Pièce ${index + 1}`),
reference: resolved?.reference || definition.reference || def.reference || null, reference: resolved?.reference || definition.reference || def.reference || null,
prix: resolved?.prix ?? null, prix: resolved?.prix ?? null,
constructeurs: resolved?.constructeurs || [], constructeurs: resolved?.constructeurs || [],
@@ -243,6 +300,7 @@ export const buildMachineHierarchyFromLinks = (
parentComponentLinkId: machineComponentLinkId, parentComponentLinkId: machineComponentLinkId,
parentComponentName: componentName, parentComponentName: componentName,
_structurePiece: true, _structurePiece: true,
_emptySlot: isEmpty,
} }
}) as AnyRecord[] }) as AnyRecord[]
@@ -279,6 +337,8 @@ export const buildMachineHierarchyFromLinks = (
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId), parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
definition: appliedComponent.definition || originalComponent?.definition || {}, definition: appliedComponent.definition || originalComponent?.definition || {},
customFields: appliedComponent.customFields || [], customFields: appliedComponent.customFields || [],
contextCustomFields: link.contextCustomFields || [],
contextCustomFieldValues: link.contextCustomFieldValues || [],
pieces, pieces,
subComponents, subComponents,
subcomponents: subComponents, subcomponents: subComponents,

View File

@@ -0,0 +1,30 @@
import { ref } from 'vue'
import { useApi } from './useApi'
const maintenanceEnabled = ref(false)
export function useMaintenance() {
const { apiCall } = useApi()
const loading = ref(false)
const fetchStatus = async () => {
const res = await apiCall<{ enabled: boolean }>('/admin/maintenance')
if (res.success && res.data) {
maintenanceEnabled.value = res.data.enabled
}
}
const toggle = async () => {
loading.value = true
try {
const res = await apiCall<{ enabled: boolean }>('/admin/maintenance', { method: 'PUT' })
if (res.success && res.data) {
maintenanceEnabled.value = res.data.enabled
}
} finally {
loading.value = false
}
}
return { maintenanceEnabled, loading, fetchStatus, toggle }
}

View File

@@ -2,13 +2,12 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from '#imports' import { useRouter } from '#imports'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks' import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { usePieceHistory } from '~/composables/usePieceHistory' import { useEntityHistory } from '~/composables/useEntityHistory'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils' import { formatPieceStructurePreview } from '~/shared/modelUtils'
@@ -21,17 +20,11 @@ import {
buildProductRequirementDescriptions, buildProductRequirementDescriptions,
buildProductRequirementEntries, buildProductRequirementEntries,
resizeProductSelections, resizeProductSelections,
areProductSelectionsFilled,
applyProductSelection, applyProductSelection,
collectNormalizedProductIds, collectNormalizedProductIds,
} from '~/shared/utils/pieceProductSelectionUtils' } from '~/shared/utils/pieceProductSelectionUtils'
import { getModelType } from '~/services/modelTypes' import { getModelType } from '~/services/modelTypes'
import { import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
interface PieceCatalogType extends ModelType { interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null structure: PieceModelStructure | null
@@ -44,7 +37,6 @@ export function usePieceEdit(pieceId: string) {
const { get } = useApi() const { get } = useApi()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { updatePiece } = usePieces() const { updatePiece } = usePieces()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast() const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments() const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
@@ -54,7 +46,7 @@ export function usePieceEdit(pieceId: string) {
loading: historyLoading, loading: historyLoading,
error: historyError, error: historyError,
loadHistory, loadHistory,
} = usePieceHistory() } = useEntityHistory('piece')
const piece = ref<any | null>(null) const piece = ref<any | null>(null)
const loading = ref(true) const loading = ref(true)
@@ -90,19 +82,29 @@ export function usePieceEdit(pieceId: string) {
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value)) const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const productSelections = ref<(string | null)[]>([]) const productSelections = ref<(string | null)[]>([])
const customFieldInputs = ref<CustomFieldInput[]>([]) // Declared early so useCustomFieldInputs can reference it.
// selectedType is defined later but is safely accessed inside a computed (lazy evaluation).
const resolvedStructure = computed<PieceModelStructure | null>(() => const resolvedStructure = computed<PieceModelStructure | null>(() =>
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null, pieceTypeDetails.value?.structure ?? null,
) )
const refreshCustomFieldInputs = ( const {
structureOverride?: PieceModelStructure | null, fields: customFieldInputs,
valuesOverride?: any[] | null, requiredFilled: requiredCustomFieldsFilled,
) => { saveAll: saveAllCustomFields,
const structure = structureOverride ?? resolvedStructure.value ?? null refresh: refreshCustomFieldInputs,
const values = valuesOverride ?? piece.value?.customFieldValues ?? null } = useCustomFieldInputs({
customFieldInputs.value = buildCustomFieldInputs(structure, values) definitions: computed(() => resolvedStructure.value?.customFields ?? []),
} values: computed(() => piece.value?.customFieldValues ?? []),
entityType: 'piece',
entityId: computed(() => piece.value?.id ?? null),
context: 'standalone',
onValueCreated: (newValue) => {
if (piece.value && Array.isArray(piece.value.customFieldValues)) {
piece.value.customFieldValues.push(newValue)
}
},
})
const openPreview = (doc: any) => { const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) { if (!doc || !canPreviewDocument(doc)) {
@@ -196,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)
@@ -221,10 +217,6 @@ export function usePieceEdit(pieceId: string) {
pendingProductIds = [] pendingProductIds = []
}) })
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() => const canSubmit = computed(() =>
Boolean( Boolean(
canEdit.value canEdit.value
@@ -247,9 +239,7 @@ export function usePieceEdit(pieceId: string) {
piece.value = result.data piece.value = result.data
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups) // The watcher on useCustomFieldInputs will auto-refresh when piece.value changes
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
// Use cached type from loadPieceTypes() instead of separate getModelType() call // Use cached type from loadPieceTypes() instead of separate getModelType() call
loadPieceTypeDetailsFromCache(result.data) loadPieceTypeDetailsFromCache(result.data)
@@ -275,14 +265,14 @@ export function usePieceEdit(pieceId: string) {
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
if (cachedType) { if (cachedType) {
pieceTypeDetails.value = cachedType pieceTypeDetails.value = cachedType
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null) // useCustomFieldInputs auto-refreshes via its watcher on resolvedStructure
return return
} }
// Fallback: fetch if not in cache (edge case) // Fallback: fetch if not in cache (edge case)
getModelType(typeId).then((type) => { getModelType(typeId).then((type) => {
if (type && typeof type === 'object') { if (type && typeof type === 'object') {
pieceTypeDetails.value = type pieceTypeDetails.value = type
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null) // useCustomFieldInputs auto-refreshes via its watcher on resolvedStructure
} }
}).catch(() => { }).catch(() => {
pieceTypeDetails.value = null pieceTypeDetails.value = null
@@ -336,29 +326,21 @@ export function usePieceEdit(pieceId: string) {
pendingProductIds = [] pendingProductIds = []
} }
// After setting selectedTypeId, read selectedType.value (now updated) instead of // useCustomFieldInputs auto-refreshes via its watcher on definitions + values
// the stale destructured currentType which was captured before the ID change.
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
initialized = true initialized = true
}, },
{ immediate: true }, { immediate: true },
) )
watch(selectedType, (currentType) => { // useCustomFieldInputs auto-refreshes when selectedType changes (via resolvedStructure)
if (!piece.value || !currentType) {
return
}
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
})
watch(resolvedStructure, (currentStructure) => { watch(resolvedStructure, () => {
if (!piece.value) { if (!piece.value) {
return return
} }
ensureProductSelections(structureProducts.value.length) ensureProductSelections(structureProducts.value.length)
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues) // useCustomFieldInputs auto-refreshes via its watcher on definitions
}) })
const submitEdition = async () => { const submitEdition = async () => {
@@ -366,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
@@ -407,15 +384,10 @@ export function usePieceEdit(pieceId: string) {
try { try {
const result = await updatePiece(piece.value.id, payload) const result = await updatePiece(piece.value.id, payload)
if (result.success && result.data) { if (result.success && result.data) {
const updatedPiece = result.data as Record<string, any> const failedFields = await saveAllCustomFields()
await _saveCustomFieldValues( if (failedFields.length) {
'piece', toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
updatedPiece.id, }
[
updatedPiece?.typePiece?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await syncLinks('piece', piece.value.id, originalConstructeurLinks.value, constructeurLinks.value) await syncLinks('piece', piece.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l })) originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
toast.showSuccess('Pièce mise à jour avec succès.') toast.showSuccess('Pièce mise à jour avec succès.')
@@ -452,6 +424,7 @@ export function usePieceEdit(pieceId: string) {
constructeurIdsFromForm, constructeurIdsFromForm,
productSelections, productSelections,
customFieldInputs, customFieldInputs,
requiredCustomFieldsFilled,
canEdit, canEdit,
// Computed // Computed

View File

@@ -1,12 +0,0 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type PieceHistoryActor = EntityHistoryActor
export type PieceHistoryEntry = EntityHistoryEntry
export function usePieceHistory() {
return useEntityHistory('piece')
}

View File

@@ -88,6 +88,7 @@ const toEditorField = (
...(typeof input?.id === 'string' && input.id ? { id: input.id } : {}), ...(typeof input?.id === 'string' && input.id ? { id: input.id } : {}),
...(typeof input?.customFieldId === 'string' && input.customFieldId ? { customFieldId: input.customFieldId } : {}), ...(typeof input?.customFieldId === 'string' && input.customFieldId ? { customFieldId: input.customFieldId } : {}),
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index, orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
machineContextOnly: Boolean(input?.machineContextOnly),
} }
} }
@@ -162,6 +163,7 @@ const buildPayload = (
type, type,
required, required,
orderIndex: index, orderIndex: index,
machineContextOnly: Boolean(field.machineContextOnly),
} }
if (field.id) { if (field.id) {
@@ -286,6 +288,7 @@ export function usePieceStructureEditorLogic(deps: Deps) {
type: 'text', type: 'text',
required: false, required: false,
optionsText: '', optionsText: '',
machineContextOnly: false,
orderIndex, orderIndex,
}) })

View File

@@ -4,7 +4,7 @@ import { useApi } from './useApi'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Piece { export interface Piece {
id: string id: string
@@ -53,17 +53,6 @@ const total = ref(0)
const loading = ref(false) const loading = ref(false)
const loaded = ref(false) const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function usePieces() { export function usePieces() {
const { showSuccess } = useToast() const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()

View File

@@ -1,12 +0,0 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ProductHistoryActor = EntityHistoryActor
export type ProductHistoryEntry = EntityHistoryEntry
export function useProductHistory() {
return useEntityHistory('product')
}

View File

@@ -5,7 +5,7 @@ import { humanizeError } from '~/shared/utils/errorMessages'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Product { export interface Product {
id: string id: string
@@ -66,17 +66,6 @@ const replaceInCache = (item: Product): boolean => {
return false return false
} }
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function useProducts() { export function useProducts() {
const { showError } = useToast() const { showError } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()

View File

@@ -56,6 +56,7 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
required: false, required: false,
optionsText: '', optionsText: '',
options: [], options: [],
machineContextOnly: false,
orderIndex: nextIndex, orderIndex: nextIndex,
}) })
reindexCustomFields() reindexCustomFields()

View File

@@ -7,6 +7,7 @@ export interface Toast {
message: string message: string
type: ToastType type: ToastType
visible: boolean visible: boolean
duration: number
} }
const toasts = ref<Toast[]>([]) const toasts = ref<Toast[]>([])
@@ -32,6 +33,7 @@ export function useToast() {
message, message,
type, type,
visible: true, visible: true,
duration,
} }
if (toasts.value.length >= MAX_TOASTS) { if (toasts.value.length >= MAX_TOASTS) {
@@ -40,10 +42,11 @@ export function useToast() {
toasts.value.push(toast) toasts.value.push(toast)
// Auto-remove after duration if (duration > 0) {
setTimeout(() => { setTimeout(() => {
removeToast(id) removeToast(id)
}, duration) }, duration)
}
return id return id
} }
@@ -52,7 +55,7 @@ export function useToast() {
return showToast(message, 'success', duration) return showToast(message, 'success', duration)
} }
const showError = (message: string, duration = 5000): number => { const showError = (message: string, duration = 8000): number => {
return showToast(message, 'error', duration) return showToast(message, 'error', duration)
} }

View File

@@ -0,0 +1,32 @@
import type { Ref } from 'vue'
export function useUnsavedGuard(isDirty: Ref<boolean>) {
const { confirm } = useConfirm()
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isDirty.value) {
e.preventDefault()
e.returnValue = ''
}
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
if (!isDirty.value) return true
const ok = await confirm({
title: 'Modifications non sauvegardées',
message: 'Vous avez des modifications en cours. Voulez-vous quitter sans sauvegarder ?',
confirmText: 'Quitter sans sauver',
cancelText: 'Rester',
dangerous: true,
})
return ok
})
}

View File

@@ -0,0 +1,52 @@
import type { Ref } from 'vue'
interface UsedInMachine {
id: string
name: string
site?: { id: string; name: string } | null
}
interface UsedInEntity {
id: string
name: string
}
interface UsedInData {
machines: UsedInMachine[]
composants: UsedInEntity[]
pieces: UsedInEntity[]
}
export function useUsedIn(entityType: Ref<'composants' | 'pieces' | 'products'>, entityId: Ref<string | null>) {
const data = ref<UsedInData>({ machines: [], composants: [], pieces: [] })
const loading = ref(false)
const api = useApi()
const load = async () => {
if (!entityId.value) return
loading.value = true
try {
const result = await api.get(`/${entityType.value}/${entityId.value}/used-in`)
if (result.success && result.data) {
data.value = {
machines: result.data.machines || [],
composants: result.data.composants || [],
pieces: result.data.pieces || [],
}
}
} finally {
loading.value = false
}
}
const totalCount = computed(() =>
data.value.machines.length + data.value.composants.length + data.value.pieces.length
)
watch(entityId, (val) => {
if (val) load()
}, { immediate: true })
return { data, loading, totalCount, load }
}

View File

@@ -0,0 +1,24 @@
export default defineNuxtRouteMiddleware((to) => {
const redirects: Record<string, string> = {
'/component-catalog': '/catalogues/composants',
'/pieces-catalog': '/catalogues/pieces',
'/product-catalog': '/catalogues/produits',
}
// Exact path match redirects
const redirect = redirects[to.path]
if (redirect) {
return navigateTo({ path: redirect, query: to.query }, { redirectCode: 301 })
}
// Category index redirects (add tab=categories query param)
if (to.path === '/component-category') {
return navigateTo({ path: '/catalogues/composants', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
}
if (to.path === '/piece-category') {
return navigateTo({ path: '/catalogues/pieces', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
}
if (to.path === '/product-category') {
return navigateTo({ path: '/catalogues/produits', query: { ...to.query, tab: 'categories' } }, { redirectCode: 301 })
}
})

View File

@@ -1,4 +1,4 @@
import { useProfileSession, usePermissions } from "#imports"; import { useProfileSession, usePermissions, useApi } from "#imports";
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
const { ensureSession, activeProfile } = useProfileSession(); const { ensureSession, activeProfile } = useProfileSession();
@@ -12,9 +12,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
normalizedPath.startsWith("/profiles") || normalizedPath.startsWith("/profiles") ||
fullPath.startsWith("/profiles") || fullPath.startsWith("/profiles") ||
routeName.startsWith("profiles"); routeName.startsWith("profiles");
const isMaintenanceRoute = normalizedPath === "/maintenance";
// Redirect to login if no active profile // Redirect to login if no active profile
if (!activeProfile.value && !isProfilesRoute) { if (!activeProfile.value && !isProfilesRoute && !isMaintenanceRoute) {
return navigateTo("/profiles"); return navigateTo("/profiles");
} }
@@ -29,5 +30,13 @@ export default defineNuxtRouteMiddleware(async (to) => {
} }
} }
// Maintenance mode check for non-admin users
if (!isAdmin.value && !isMaintenanceRoute && !isProfilesRoute) {
const { apiCall } = useApi();
const res = await apiCall<{ enabled: boolean }>('/maintenance/check');
if (res.success && res.data?.enabled) {
return navigateTo("/maintenance");
}
}
} }
}); });

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

@@ -1,5 +1,28 @@
<template> <template>
<div class="container mx-auto p-6 max-w-6xl"> <div class="container mx-auto p-6 max-w-6xl">
<!-- Maintenance Mode -->
<div class="alert mb-6" :class="maintenanceEnabled ? 'alert-warning' : 'alert-info'">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span class="font-medium">Mode maintenance</span>
<span v-if="maintenanceEnabled" class="badge badge-warning badge-sm">Actif</span>
<span v-else class="badge badge-ghost badge-sm">Inactif</span>
</div>
<button
class="btn btn-sm"
:class="maintenanceEnabled ? 'btn-ghost' : 'btn-warning'"
:disabled="maintenanceLoading"
@click="handleToggleMaintenance"
>
<span v-if="maintenanceLoading" class="loading loading-spinner loading-xs" />
{{ maintenanceEnabled ? 'Désactiver' : 'Activer' }}
</button>
</div>
<p class="text-sm opacity-70 mt-1">
{{ maintenanceEnabled ? 'Seuls les administrateurs peuvent accéder à l\'application.' : 'L\'application est accessible à tous les utilisateurs.' }}
</p>
</div>
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold"> <h1 class="text-2xl font-bold">
Administration des profils Administration des profils
@@ -153,9 +176,14 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import { useAdminProfiles } from '#imports' import { useAdminProfiles, useMaintenance } from '#imports'
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles() const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
const { maintenanceEnabled, loading: maintenanceLoading, fetchStatus: fetchMaintenanceStatus, toggle: toggleMaintenance } = useMaintenance()
const handleToggleMaintenance = async () => {
await toggleMaintenance()
}
const loaded = ref(false) const loaded = ref(false)
const isLoading = computed(() => loading.value || !loaded.value) const isLoading = computed(() => loading.value || !loaded.value)
@@ -264,7 +292,7 @@ const handleDeactivate = async (profileId) => {
} }
onMounted(async () => { onMounted(async () => {
await fetchAll() await Promise.all([fetchAll(), fetchMaintenanceStatus()])
loaded.value = true loaded.value = true
}) })
</script> </script>

Some files were not shown because too many files have changed in this diff Show More