Compare commits

..

82 Commits

Author SHA1 Message Date
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
102 changed files with 12906 additions and 6444 deletions

View File

@@ -4,7 +4,7 @@
.env.test
infra/dev/
infra/prod/docker-compose.yml
infra/prod/deploy.sh
infra/prod/deploy.sh.example
infra/prod/.env.example
frontend/node_modules
frontend/.nuxt

View File

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

175
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2db01f705a09cf38007a2baa3b078e49",
"content-hash": "f94dc3c05e9ba6be99c510aad3d17182",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -5341,6 +5341,92 @@
],
"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/options-resolver",
"version": "v8.0.0",
@@ -5567,6 +5653,93 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"symfony/polyfill-intl-normalizer": "^1.10"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Idn\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laurent Bassin",
"email": "laurent@bassin.info"
},
{
"name": "Trevor Rowbotham",
"email": "trevor.rowbotham@pm.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"idn",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",

View File

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

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.15'
app.version: '1.9.25'

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

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

View File

@@ -7,6 +7,7 @@
@open-settings="displaySettingsOpen = true"
@logout="handleLogout"
/>
<AppBreadcrumb />
<main class="flex-1">
<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 ok = await confirm({
title: 'Supprimer ce commentaire ?',
message: 'Cette action est irréversible.',
confirmText: 'Supprimer',
dangerous: true,
})
if (!ok) return
const result = await deleteComment(commentId)
if (result.success) {
comments.value = comments.value.filter(c => c.id !== commentId)

View File

@@ -14,7 +14,14 @@
/>
<!-- Component Header -->
<div class="flex items-center gap-3 p-3 rounded-lg cursor-pointer" :class="component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'" @click="toggleCollapse">
<div
class="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-shadow"
:class="[
component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200',
!isCollapsed ? 'sticky top-16 z-10 shadow-sm' : '',
]"
@click="toggleCollapse"
>
<IconLucideChevronRight
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
:class="{ 'rotate-90': !isCollapsed }"
@@ -23,7 +30,17 @@
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-semibold 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:underline hover:text-primary transition-colors"
@click.stop
>
{{ component.name }}
</NuxtLink>
<span v-else>{{ component.name }}</span>
</h3>
<button
v-if="component.pendingEntity"
@@ -312,7 +329,7 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import PieceItem from './PieceItem.vue'
import DocumentUpload from './DocumentUpload.vue'
@@ -335,13 +352,11 @@ import {
} from '~/shared/utils/documentDisplayUtils'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
import {
mergeFieldDefinitionsWithValues,
dedupeMergedFields,
resolveCustomFieldId,
resolveFieldId,
} from '~/shared/utils/entityCustomFieldLogic'
import { useCustomFields } from '~/composables/useCustomFields'
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
const props = defineProps({
component: { type: Object, required: true },
@@ -377,25 +392,81 @@ const {
} = useEntityProductDisplay({ entity: () => props.component })
const {
displayedCustomFields,
updateCustomField: updateComponentCustomField,
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
updateCustomFieldValue: updateCustomFieldValueApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Parent already pre-merges standalone custom fields into props.component.customFields
const displayedCustomFields = computed(() => {
const fields = props.component?.customFields
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
})
const updateComponentCustomField = async (field) => {
if (!field || field.readOnly) return
const e = props.component
const fieldValueId = field.customFieldValueId
if (fieldValueId) {
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
}
return
}
if (!e?.id) {
showError('Impossible de créer la valeur pour ce champ')
return
}
const metadata = field.customFieldId ? undefined : {
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
}
const result = await upsertCustomFieldValue(
field.customFieldId,
'composant',
e.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
}
}
showSuccess(`Champ "${field.name}" créé avec succès`)
} else {
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
}
}
// Context fields are NOT pre-merged — merge locally
const mergedContextFields = computed(() => {
const definitions = props.component?.contextCustomFields ?? []
const values = props.component?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return dedupeMergedFields(
mergeFieldDefinitionsWithValues(definitions, values),
)
return mergeDefinitionsWithValues(definitions, values)
})
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.component?.linkId
if (!linkId || !field) return
const customFieldId = resolveCustomFieldId(field)
const customFieldValueId = resolveFieldId(field)
const customFieldId = field.customFieldId
const customFieldValueId = field.customFieldValueId
if (!customFieldId && !customFieldValueId) return
field.value = value
@@ -405,7 +476,7 @@ const queueContextCustomFieldUpdate = (field, value) => {
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || field.customField?.name || 'Champ contextuel',
fieldName: field.name || 'Champ contextuel',
})
}

View File

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

View File

@@ -29,7 +29,17 @@
</button>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
{{ pieceData.name }}
<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:underline hover:text-primary transition-colors"
@click.stop
>
{{ pieceData.name }}
</NuxtLink>
<template v-else>{{ pieceData.name }}</template>
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1"> manquant</span>
<button
v-if="piece.pendingEntity"
@@ -304,7 +314,7 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { reactive, ref, onMounted, watch, computed } from 'vue'
import ConstructeurSelect from './ConstructeurSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue'
@@ -319,16 +329,13 @@ import {
uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
import {
resolveFieldId,
resolveFieldReadOnly,
resolveCustomFieldId,
mergeFieldDefinitionsWithValues,
dedupeMergedFields,
} from '~/shared/utils/entityCustomFieldLogic'
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
import { useCustomFields } from '~/composables/useCustomFields'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
const props = defineProps({
piece: { type: Object, required: true },
@@ -392,25 +399,81 @@ const {
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
const {
displayedCustomFields,
updateCustomField,
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
updateCustomFieldValue: updateCustomFieldValueApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Parent already pre-merges standalone custom fields into props.piece.customFields
const displayedCustomFields = computed(() => {
const fields = props.piece?.customFields
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
})
const updateCustomField = async (field) => {
if (!field || field.readOnly) return
const e = props.piece
const fieldValueId = field.customFieldValueId
if (fieldValueId) {
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
}
return
}
if (!e?.id) {
showError('Impossible de créer la valeur pour ce champ')
return
}
const metadata = field.customFieldId ? undefined : {
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
}
const result = await upsertCustomFieldValue(
field.customFieldId,
'piece',
e.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
}
}
showSuccess(`Champ "${field.name}" créé avec succès`)
} else {
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
}
}
// Context fields are NOT pre-merged — merge locally
const mergedContextFields = computed(() => {
const definitions = props.piece?.contextCustomFields ?? []
const values = props.piece?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return dedupeMergedFields(
mergeFieldDefinitionsWithValues(definitions, values),
)
return mergeDefinitionsWithValues(definitions, values)
})
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.piece?.linkId
if (!linkId || !field) return
const customFieldId = resolveCustomFieldId(field)
const customFieldValueId = resolveFieldId(field)
const customFieldId = field.customFieldId
const customFieldValueId = field.customFieldValueId
if (!customFieldId && !customFieldValueId) return
field.value = value
@@ -420,7 +483,7 @@ const queueContextCustomFieldUpdate = (field, value) => {
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || field.customField?.name || 'Champ contextuel',
fieldName: field.name || 'Champ contextuel',
})
}
@@ -544,8 +607,8 @@ const handleProductChange = async (value) => {
// --- Custom field event handlers ---
const handleCustomFieldInput = (field, value) => {
if (resolveFieldReadOnly(field)) return
const fieldValueId = resolveFieldId(field)
if (field.readOnly) return
const fieldValueId = field.customFieldValueId
if (!fieldValueId) return
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
if (fieldValue) fieldValue.value = value
@@ -553,7 +616,7 @@ const handleCustomFieldInput = (field, value) => {
const handleCustomFieldBlur = async (field) => {
await updateCustomField(field)
const cfId = field?.customFieldId || field?.customField?.id || null
const cfId = field?.customFieldId || null
if (cfId || field?.customFieldValueId) {
emit('custom-field-update', {
fieldId: cfId,

View File

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

View File

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

View File

@@ -9,15 +9,15 @@
<div :class="layoutClass">
<div
v-for="(field, index) in fields"
:key="resolveFieldKey(field, index)"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{
resolveFieldName(field)
field.name
}}</span>
<span
v-if="resolveFieldRequired(field)"
v-if="field.required"
class="label-text-alt text-error"
>*</span>
</label>
@@ -26,32 +26,32 @@
<template v-if="isFieldEditable(field)">
<!-- Champ de type TEXT -->
<input
v-if="resolveFieldType(field) === 'text'"
v-if="field.type === 'text'"
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
:required="field.required"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
<!-- Champ de type NUMBER -->
<input
v-else-if="resolveFieldType(field) === 'number'"
v-else-if="field.type === 'number'"
:value="field.value ?? ''"
type="number"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
:required="field.required"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
<!-- Champ de type SELECT -->
<select
v-else-if="resolveFieldType(field) === 'select'"
v-else-if="field.type === 'select'"
:value="field.value ?? ''"
class="select select-bordered select-sm"
:required="resolveFieldRequired(field)"
:required="field.required"
@change="onInput(field, ($event.target as HTMLSelectElement).value)"
@blur="onBlur(field)"
>
@@ -59,7 +59,7 @@
Sélectionner...
</option>
<option
v-for="option in resolveFieldOptions(field)"
v-for="option in field.options"
:key="option"
:value="option"
>
@@ -69,7 +69,7 @@
<!-- Champ de type BOOLEAN -->
<div
v-else-if="resolveFieldType(field) === 'boolean'"
v-else-if="field.type === 'boolean'"
class="flex items-center gap-2"
>
<input
@@ -85,21 +85,21 @@
<!-- Champ de type DATE -->
<input
v-else-if="resolveFieldType(field) === 'date'"
v-else-if="field.type === 'date'"
:value="field.value ?? ''"
type="date"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
:required="field.required"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
<!-- Champ de type TEXTAREA -->
<textarea
v-else-if="resolveFieldType(field) === 'textarea'"
v-else-if="field.type === 'textarea'"
:value="field.value ?? ''"
class="textarea textarea-bordered textarea-sm"
:required="resolveFieldRequired(field)"
:required="field.required"
@input="onInput(field, ($event.target as HTMLTextAreaElement).value)"
@blur="onBlur(field)"
/>
@@ -110,7 +110,7 @@
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
:required="field.required"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
@@ -119,7 +119,7 @@
<!-- Mode lecture seule -->
<template v-else>
<div class="input input-bordered input-sm bg-base-200">
{{ formatFieldDisplayValue(field) }}
{{ formatValueForDisplay(field) }}
</div>
</template>
</div>
@@ -128,18 +128,10 @@
</template>
<script setup lang="ts">
import {
resolveFieldKey,
resolveFieldName,
resolveFieldType,
resolveFieldOptions,
resolveFieldRequired,
resolveFieldReadOnly,
formatFieldDisplayValue,
} from '~/shared/utils/entityCustomFieldLogic'
import { fieldKey, formatValueForDisplay, type CustomFieldInput } from '~/shared/utils/customFields'
const props = defineProps<{
fields: any[]
fields: CustomFieldInput[]
isEditMode: boolean
columns?: 1 | 2
title?: string
@@ -150,8 +142,8 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
'field-input': [field: any, value: string]
'field-blur': [field: any]
'field-input': [field: CustomFieldInput, value: string]
'field-blur': [field: CustomFieldInput]
}>()
const layoutClass = computed(() =>
@@ -170,16 +162,16 @@ const containerClass = computed(() =>
const editable = computed(() => props.editable ?? true)
const emitBlur = computed(() => props.emitBlur ?? true)
function isFieldEditable(field: any) {
return props.isEditMode && editable.value && !resolveFieldReadOnly(field)
function isFieldEditable(field: CustomFieldInput) {
return props.isEditMode && editable.value && !field.readOnly
}
function onInput(field: any, value: string) {
function onInput(field: CustomFieldInput, value: string) {
field.value = value
emit('field-input', field, value)
}
function onBooleanChange(field: any, checked: boolean) {
function onBooleanChange(field: CustomFieldInput, checked: boolean) {
const value = checked ? 'true' : 'false'
field.value = value
emit('field-input', field, value)
@@ -188,7 +180,7 @@ function onBooleanChange(field: any, checked: boolean) {
}
}
function onBlur(field: any) {
function onBlur(field: CustomFieldInput) {
if (emitBlur.value) {
emit('field-blur', field)
}

View File

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

View File

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

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

View File

@@ -2,7 +2,10 @@
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2>
<h2 class="card-title">
Composants
<span v-if="components.length" class="badge badge-outline badge-sm ml-1">{{ components.length }}</span>
</h2>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,10 @@
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2>
<h2 class="card-title">
Pièces de la machine
<span v-if="pieces.length" class="badge badge-outline badge-sm ml-1">{{ pieces.length }}</span>
</h2>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"

View File

@@ -29,7 +29,16 @@
>
<div class="flex items-center justify-between flex-wrap gap-2">
<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>
<div class="flex items-center gap-2">
<button
@@ -133,7 +142,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
@@ -142,6 +151,9 @@ import {
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
defineProps<{
products: Array<{
id?: string | null

View File

@@ -2,12 +2,14 @@
<main
class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8"
>
<header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
<p class="text-base text-base-content/70">
{{ descriptionText }}
</p>
</header>
<template v-if="!hideHeading">
<header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
<p class="text-base text-base-content/70">
{{ descriptionText }}
</p>
</header>
</template>
<nav
v-if="allowCategorySwitch"
@@ -55,16 +57,6 @@
/>
</label>
<button
v-if="canEdit"
type="button"
class="btn btn-primary btn-sm"
:disabled="loading"
@click="openCreatePage"
>
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
Créer
</button>
</template>
<template #cell-name="{ row }">
@@ -76,19 +68,15 @@
<span v-else class="text-base-content/50"></span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
Liés
</button>
<button
v-if="canEdit && showConvertButton"
type="button"
class="btn btn-ghost btn-xs text-warning"
@click="openConversionModal(row)"
>
Convertir
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
Éditer
</button>
@@ -99,13 +87,6 @@
</template>
</DataTable>
<ConversionModal
:open="conversionModalOpen"
:model-type="conversionTarget"
@close="closeConversionModal"
@converted="onConverted"
/>
<RelatedItemsModal
:open="relatedModalOpen"
:model-type="relatedType"
@@ -119,7 +100,6 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import { useHead, useRouter } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import ConversionModal from '~/components/model-types/ConversionModal.vue'
import { useUrlState } from '~/composables/useUrlState'
import type { DataTableSort } from '~/shared/types/dataTable'
import {
@@ -133,7 +113,7 @@ import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import IconLucideSearch from '~icons/lucide/search'
import IconLucidePlus from '~icons/lucide/plus'
import { formatFrenchDate } from '~/utils/date'
const DEFAULT_DESCRIPTION
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
@@ -144,9 +124,11 @@ const props = withDefaults(
heading: string
description?: string
allowCategorySwitch?: boolean
hideHeading?: boolean
}>(),
{
allowCategorySwitch: false,
hideHeading: false,
},
)
@@ -195,12 +177,11 @@ useHead(() => ({ title: headingText.value }))
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'notes', label: 'Notes' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
]
const showConvertButton = computed(() =>
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
)
const formatDate = formatFrenchDate
const categories: Array<{ label: string, value: ModelCategory }> = [
{ label: 'Composants', value: 'COMPONENT' },
@@ -335,13 +316,6 @@ const resolveCategoryBasePath = (category: ModelCategory) => {
return '/product-category'
}
const openCreatePage = () => {
const basePath = resolveCategoryBasePath(selectedCategory.value)
router.push(`${basePath}/new`).catch(() => {
showError('Navigation impossible vers la page de création.')
})
}
const openEditPage = (item: ModelType) => {
const category = item.category ?? selectedCategory.value
const basePath = resolveCategoryBasePath(category)
@@ -396,26 +370,6 @@ const openRelatedEdit = (entry: { id: string }) => {
})
}
const conversionModalOpen = ref(false)
const conversionTarget = ref<ModelType | null>(null)
const openConversionModal = (item: ModelType) => {
conversionTarget.value = item
conversionModalOpen.value = true
}
const closeConversionModal = () => {
conversionModalOpen.value = false
}
const onConverted = () => {
conversionModalOpen.value = false
invalidateEntityTypeCache('PIECE')
invalidateEntityTypeCache('COMPONENT')
showSuccess('Catégorie convertie avec succès.')
doRefresh()
}
watch(
() => searchInput.value,
(value) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,14 +6,13 @@ import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useComponentHistory } from '~/composables/useComponentHistory'
import { useEntityHistory } from '~/composables/useEntityHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
@@ -29,12 +28,7 @@ import {
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { useCustomFieldInputs, type CustomFieldInput } from '~/composables/useCustomFieldInputs'
import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils'
interface ComponentCatalogType extends ModelType {
@@ -64,7 +58,6 @@ export function useComponentEdit(componentId: string) {
const { products } = useProducts()
const { ensureConstructeurs } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
const {
@@ -72,7 +65,7 @@ export function useComponentEdit(componentId: string) {
loading: historyLoading,
error: historyError,
loadHistory,
} = useComponentHistory()
} = useEntityHistory('composant')
const component = ref<any | null>(null)
const loading = ref(true)
@@ -96,7 +89,6 @@ export function useComponentEdit(componentId: string) {
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const customFieldInputs = ref<CustomFieldInput[]>([])
const fetchedPieceTypeMap = ref<Record<string, string>>({})
const pieceTypeLabelMap = computed(() =>
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
@@ -207,18 +199,23 @@ export function useComponentEdit(componentId: string) {
return structure ? normalizeStructureForEditor(structure) : null
})
const refreshCustomFieldInputs = (
structureOverride?: ComponentModelStructure | null,
valuesOverride?: any[] | null,
) => {
const structure = structureOverride ?? selectedTypeStructure.value ?? null
const values = valuesOverride ?? component.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values)
}
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const {
fields: customFieldInputs,
requiredFilled: requiredCustomFieldsFilled,
saveAll: saveAllCustomFields,
refresh: refreshCustomFieldInputs,
} = useCustomFieldInputs({
definitions: computed(() => selectedTypeStructure.value?.customFields ?? []),
values: computed(() => component.value?.customFieldValues ?? []),
entityType: 'composant',
entityId: computed(() => component.value?.id ?? null),
context: 'standalone',
onValueCreated: (newValue) => {
if (component.value && Array.isArray(component.value.customFieldValues)) {
component.value.customFieldValues.push(newValue)
}
},
})
const canSubmit = computed(() => Boolean(
canEdit.value
@@ -239,8 +236,7 @@ export function useComponentEdit(componentId: string) {
component.value = result.data
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
// The watcher on useCustomFieldInputs will auto-refresh when component.value changes
loadHistory(result.data.id).catch(() => {})
}
@@ -392,14 +388,10 @@ export function useComponentEdit(componentId: string) {
const result = await updateComposant(component.value.id, payload)
if (result.success && result.data) {
const updatedComponent = result.data as Record<string, any>
await _saveCustomFieldValues(
'composant',
updatedComponent.id,
[
updatedComponent?.typeComposant?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Erreur sur les champs : ${failedFields.join(', ')}`)
}
// Save slot edits
const slotPromises: Promise<any>[] = []
@@ -499,7 +491,7 @@ export function useComponentEdit(componentId: string) {
initialized.value = true
}
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
// useCustomFieldInputs auto-refreshes via its watcher on definitions + values
},
{ immediate: true },
)
@@ -565,6 +557,7 @@ export function useComponentEdit(componentId: string) {
originalConstructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
requiredCustomFieldsFilled,
historyFieldLabels,
// 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 { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Composant {
id: string
@@ -51,17 +51,6 @@ const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function useComposants() {
const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi()

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

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,
),
).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly),
)
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 { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { normalizeStructureForEditor } from '~/shared/modelUtils'
import {
shouldDisplayCustomField,
normalizeExistingCustomFieldDefinitions,
normalizeCustomFieldValueEntry,
mergeCustomFieldValuesWithDefinitions,
dedupeCustomFieldEntries,
} from '~/shared/utils/customFieldUtils'
mergeDefinitionsWithValues,
filterByContext,
hasDisplayableValue,
type CustomFieldInput,
} from '~/shared/utils/customFields'
import {
resolveConstructeurs,
uniqueConstructeurIds,
@@ -53,56 +51,23 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
const visibleMachineCustomFields = computed(() => {
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
if (isEditMode.value) return fields
return fields.filter((field) => shouldDisplayCustomField(field))
return fields.filter((field) => hasDisplayableValue(field as unknown as CustomFieldInput))
})
// ---------------------------------------------------------------------------
// Transform helpers
// ---------------------------------------------------------------------------
const getStructureCustomFields = (structure: unknown): AnyRecord[] => {
if (!structure || typeof structure !== 'object') return []
const normalized = normalizeStructureForEditor(structure as any) as any
return Array.isArray(normalized?.customFields)
? (normalized.customFields as AnyRecord[])
: []
}
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
return (piecesData || []).map((piece) => {
const typePiece = (piece.typePiece as AnyRecord) || {}
const normalizeStructureDefs = (structure: unknown) =>
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
const normalizedStructureDefs = [
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
normalizeStructureDefs(typePiece.structure),
]
const valueEntries = [
...(Array.isArray(piece.customFieldValues) ? piece.customFieldValues : []),
...(Array.isArray(piece.customFields)
? (piece.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
...(Array.isArray(typePiece.customFieldValues)
? (typePiece.customFieldValues as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const customFields = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(piece.customFields),
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
const customFields = filterByContext(
mergeDefinitionsWithValues(
typePiece.customFields ?? (piece.typePiece as AnyRecord)?.customFields ?? [],
piece.customFieldValues ?? [],
),
'standalone',
)
const constructeurIds = uniqueConstructeurIds(
@@ -159,43 +124,16 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
}
const transformComponentCustomFields = (componentsData: AnyRecord[]): AnyRecord[] => {
const normalizeStructureDefs = (structure: unknown) =>
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
return (componentsData || []).map((component) => {
const type = (component.typeComposant as AnyRecord) || {}
const normalizedStructureDefs = [
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
normalizeStructureDefs(type.structure),
]
const actualComponent = (component.originalComposant as AnyRecord) || component
const valueEntries = [
...(Array.isArray(component.customFieldValues) ? component.customFieldValues : []),
...(Array.isArray(component.customFields)
? (component.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
...(Array.isArray(actualComponent?.customFields)
? (actualComponent.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const customFields = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(component.customFields),
normalizeExistingCustomFieldDefinitions((component.definition as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(type.customFields),
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
const customFields = filterByContext(
mergeDefinitionsWithValues(
type.customFields ?? [],
component.customFieldValues ?? actualComponent?.customFieldValues ?? [],
),
'standalone',
)
const piecesTransformed = component.pieces
@@ -271,21 +209,11 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
machineCustomFields.value = []
return
}
const valueEntries = [
...(Array.isArray(machine.value.customFieldValues) ? machine.value.customFieldValues : []),
...(Array.isArray(machine.value.customFields)
? (machine.value.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const merged = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
),
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
machineCustomFields.value = merged
const merged = mergeDefinitionsWithValues(
machine.value?.customFields ?? [],
machine.value?.customFieldValues ?? [],
)
machineCustomFields.value = merged.map(f => ({ ...f, readOnly: false }))
}
const setMachineCustomFieldValue = (field: AnyRecord, value: unknown) => {
@@ -302,7 +230,8 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
const updateMachineCustomField = async (field: AnyRecord) => {
if (!machine.value || !field) return
const { id: customFieldId, customFieldValueId } = field
const customFieldId = (field.customFieldId ?? field.id) as string | undefined
const customFieldValueId = field.customFieldValueId as string | undefined
const fieldLabel = (field.name as string) || 'Champ personnalisé'
try {
@@ -467,7 +396,8 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
)
for (const field of fieldsToSave) {
const { id: customFieldId, customFieldValueId } = field
const customFieldId = (field.customFieldId ?? field.id) as string | undefined
const customFieldValueId = field.customFieldValueId as string | undefined
try {
if (customFieldValueId) {

View File

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

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

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

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

View File

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

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

@@ -0,0 +1,239 @@
<template>
<main class="container mx-auto px-6 py-10">
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Composants</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de composants.</p>
</div>
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/component-category/new' : '/component/create'" class="btn btn-primary btn-sm md:btn-md">
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter un composant' }}
</NuxtLink>
</div>
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Composants">
<template #tab-catalogue>
<section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<header class="flex flex-col gap-1">
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
<p class="text-sm text-base-content/50">
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
</p>
</header>
<DataTable
:columns="columns"
:rows="componentRows"
:loading="loadingComposants"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun composant n'a encore été créé."
no-results-message="Aucun composant ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.component)"
:alt="resolvePreviewAlt(row.component)"
/>
</template>
<template #cell-name="{ row }">
{{ row.component.name || 'Composant sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.component.reference || '—' }}
</template>
<template #cell-referenceAuto="{ row }">
{{ row.component.referenceAuto || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.component.description" class="group relative">
<span class="block cursor-help truncate">{{ row.component.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
</div>
</div>
<span v-else></span>
</template>
<template #cell-typeComposant="{ row }">
<NuxtLink
v-if="row.component.typeComposant?.id"
:to="`/component-category/${row.component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(row.component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(row.component) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingComposants"
@click="handleDeleteComponent(row.component)"
>
Supprimer
</button>
<NuxtLink
:to="`/component/${row.component.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</template>
<template #tab-categories>
<ManagementView
category="COMPONENT"
heading="Catégories de composant"
:hide-heading="true"
/>
</template>
</EntityTabs>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import ManagementView from '~/components/model-types/ManagementView.vue'
import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const route = useRoute()
const { canEdit } = usePermissions()
const activeTab = ref((route.query.tab as string) || 'catalogue')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const pageTabs = computed(() => {
const t: Array<{ key: string; label: string }> = [
{ key: 'catalogue', label: 'Catalogue' },
]
if (canEdit.value) {
t.push({ key: 'categories', label: 'Catégories' })
}
return t
})
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const table = useDataTable(
{ fetchData: fetchComposants },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'referenceAuto', label: 'Réf. auto' },
{ key: 'description', label: 'Description' },
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const composantsOnPage = computed(() => componentRows.value.length)
const paginationState = table.pagination(total, composantsOnPage)
const composantsList = computed(() => {
return (composants.value || []).map((composant) => {
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
})
})
const componentRows = computed(() =>
composantsList.value.map(component => ({
id: component.id,
component,
})),
)
async function fetchComposants() {
await loadComposants({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typeComposant || undefined,
force: true,
})
}
const resolveComponentType = (component: Record<string, any>) => {
if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel
return '—'
}
const { confirm } = useConfirm()
const handleDeleteComponent = async (component: Record<string, any>) => {
const componentName = component?.name || 'ce composant'
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
if (!confirmed) return
await deleteComposant(component.id)
fetchComposants()
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchComposants(), loadComponentTypes()])
})
</script>

View File

@@ -0,0 +1,267 @@
<template>
<main class="container mx-auto px-6 py-10">
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Pièces</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de pièces.</p>
</div>
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/piece-category/new' : '/pieces/create'" class="btn btn-primary btn-sm md:btn-md">
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter une pièce' }}
</NuxtLink>
</div>
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Pièces">
<template #tab-catalogue>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<header class="flex flex-col gap-2">
<h2 class="text-xl font-semibold text-base-content">Pièces créées</h2>
<p class="text-sm text-base-content/70">
Liste globale des pièces enregistrées, quel que soit leur squelette d'origine.
</p>
</header>
<DataTable
:columns="columns"
:rows="pieceRows"
:loading="loadingPieces"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucune pièce n'a encore été créée."
no-results-message="Aucune pièce ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.piece)"
:alt="resolvePreviewAlt(row.piece)"
/>
</template>
<template #cell-name="{ row }">
{{ row.piece.name || 'Pièce sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.piece.reference || '—' }}
</template>
<template #cell-referenceAuto="{ row }">
{{ row.piece.referenceAuto || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-sm group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
</div>
</div>
<span v-else>—</span>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else>—</span>
</template>
<template #cell-typePiece="{ row }">
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/piece/${row.piece.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>
Supprimer
</button>
<NuxtLink
:to="`/piece/${row.piece.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</template>
<template #tab-categories>
<ManagementView
category="PIECE"
heading="Catégories de pièce"
:hide-heading="true"
/>
</template>
</EntityTabs>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import ManagementView from '~/components/model-types/ManagementView.vue'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const route = useRoute()
const { canEdit } = usePermissions()
const activeTab = ref((route.query.tab as string) || 'catalogue')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const pageTabs = computed(() => {
const t: Array<{ key: string; label: string }> = [
{ key: 'catalogue', label: 'Catalogue' },
]
if (canEdit.value) {
t.push({ key: 'categories', label: 'Catégories' })
}
return t
})
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const table = useDataTable(
{ fetchData: fetchPieces },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typePiece'] },
)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'referenceAuto', label: 'Réf. auto' },
{ key: 'description', label: 'Description' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const piecesOnPage = computed(() => pieceRows.value.length)
const paginationState = table.pagination(total, piecesOnPage)
const piecesList = computed(() => {
return (pieces.value || []).map((piece) => {
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
return { ...piece, typePiece: typePiece || piece.typePiece || null }
})
})
const pieceRows = computed(() =>
piecesList.value.map(piece => ({
id: piece.id,
piece,
suppliers: buildPieceSuppliersDisplay(piece),
})),
)
async function fetchPieces() {
await loadPieces({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typePiece || undefined,
force: true,
})
}
const resolvePieceType = (piece: Record<string, any>) => {
if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel
return '—'
}
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm()
const handleDeletePiece = async (piece: Record<string, any>) => {
const pieceName = piece?.name || 'cette pièce'
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
if (!confirmed) return
await deletePiece(piece.id)
fetchPieces()
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchPieces(), loadPieceTypes()])
})
</script>

View File

@@ -0,0 +1,278 @@
<template>
<main class="container mx-auto px-6 py-10">
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Produits</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de produits.</p>
</div>
<NuxtLink v-if="canEdit" :to="activeTab === 'categories' ? '/product-category/new' : '/product/create'" class="btn btn-primary btn-sm md:btn-md">
{{ activeTab === 'categories' ? 'Ajouter une catégorie' : 'Ajouter un produit' }}
</NuxtLink>
</div>
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Produits">
<template #tab-catalogue>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div
v-if="errorMessage"
class="alert alert-error"
>
<div class="flex flex-col gap-1">
<span class="font-semibold">Impossible de charger les produits</span>
<span class="text-sm">{{ errorMessage }}</span>
</div>
<button type="button" class="btn btn-ghost btn-sm ml-auto" @click="reload">
Réessayer
</button>
</div>
<DataTable
v-else
:columns="columns"
:rows="productRows"
:loading="loading"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun produit n'a encore été enregistré."
no-results-message="Aucun produit ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.product, true)"
:alt="resolvePreviewAlt(row.product)"
/>
</template>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.product.name }}</span>
</template>
<template #cell-reference="{ row }">
{{ row.product.reference || '—' }}
</template>
<template #cell-typeProduct="{ row }">
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '' }}</span>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else class="text-sm text-base-content/50"></span>
</template>
<template #cell-price="{ row }">
{{ formatPrice(row.product.supplierPrice) }}
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/product/${row.product.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)"
>
Supprimer
</button>
<NuxtLink
:to="`/product/${row.product.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</template>
<template #tab-categories>
<ManagementView
category="PRODUCT"
heading="Catégories de produit"
:hide-heading="true"
/>
</template>
</EntityTabs>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useHead } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import ManagementView from '~/components/model-types/ManagementView.vue'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
const route = useRoute()
const { canEdit } = usePermissions()
const activeTab = ref((route.query.tab as string) || 'catalogue')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const pageTabs = computed(() => {
const t: Array<{ key: string; label: string }> = [
{ key: 'catalogue', label: 'Catalogue' },
]
if (canEdit.value) {
t.push({ key: 'categories', label: 'Catégories' })
}
return t
})
useHead(() => ({ title: 'Catalogue des produits' }))
const {
products,
total,
loading,
error,
loadProducts,
deleteProduct,
} = useProducts()
const { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast()
const table = useDataTable(
{ fetchData: fetchProducts },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeProduct'] },
)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
]
const productsOnPage = computed(() => productRows.value.length)
const paginationState = table.pagination(total, productsOnPage)
const normalizedProducts = computed(() => {
return (Array.isArray(products.value) ? products.value : []).map((product) => {
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
})
})
const productRows = computed(() =>
normalizedProducts.value.map(product => ({
id: product.id,
product,
suppliers: buildProductSuppliersDisplay(product),
})),
)
async function fetchProducts() {
await loadProducts({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typeProduct || undefined,
force: true,
})
}
const priceFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
const formatPrice = (value: any) => {
if (value === null || value === undefined || value === '') return '—'
const number = Number(value)
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
}
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(product))
const reload = () => fetchProducts()
const { confirm } = useConfirm()
const confirmDelete = async (product: Record<string, any>) => {
const productName = product?.name || 'ce produit'
const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
if (!confirmed) return
const result = await deleteProduct(product.id)
if (result.success) {
toast.showSuccess(`Produit "${productName}" supprimé`)
}
}
onMounted(async () => {
await Promise.all([fetchProducts(), loadProductTypes()])
})
</script>

View File

@@ -1,212 +0,0 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold text-base-content tracking-tight">Catalogue des composants</h1>
<p class="text-sm text-base-content/50 mt-1">
Consultez et gérez tous les composants existants.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un composant
</NuxtLink>
<NuxtLink to="/component-category" class="btn btn-ghost btn-sm md:btn-md">
Gérer les catégories
</NuxtLink>
</div>
</header>
<section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<header class="flex flex-col gap-1">
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
<p class="text-sm text-base-content/50">
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
</p>
</header>
<DataTable
:columns="columns"
:rows="componentRows"
:loading="loadingComposants"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun composant n'a encore été créé."
no-results-message="Aucun composant ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence…"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.component)"
:alt="resolvePreviewAlt(row.component)"
/>
</template>
<template #cell-name="{ row }">
{{ row.component.name || 'Composant sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.component.reference || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.component.description" class="group relative">
<span class="block cursor-help truncate">{{ row.component.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
</div>
</div>
<span v-else></span>
</template>
<template #cell-typeComposant="{ row }">
<NuxtLink
v-if="row.component.typeComposant?.id"
:to="`/component-category/${row.component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(row.component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(row.component) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingComposants"
@click="handleDeleteComponent(row.component)"
>
Supprimer
</button>
<NuxtLink
:to="`/component/${row.component.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions()
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const table = useDataTable(
{ fetchData: fetchComposants },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'description', label: 'Description' },
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const composantsOnPage = computed(() => componentRows.value.length)
const paginationState = table.pagination(total, composantsOnPage)
// Enrich composants with full type data
const composantsList = computed(() => {
return (composants.value || []).map((composant) => {
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
})
})
const componentRows = computed(() =>
composantsList.value.map(component => ({
id: component.id,
component,
})),
)
async function fetchComposants() {
await loadComposants({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typeComposant || undefined,
force: true,
})
}
const resolveComponentType = (component: Record<string, any>) => {
if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel
return '—'
}
const { confirm } = useConfirm()
const handleDeleteComponent = async (component: Record<string, any>) => {
const componentName = component?.name || 'ce composant'
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
if (!confirmed) return
await deleteComposant(component.id)
fetchComposants()
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchComposants(), loadComponentTypes()])
})
</script>

View File

@@ -1,452 +0,0 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="componentDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du composant</p>
</div>
<div v-else-if="!component" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Composant introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier le composant</h1>
<p class="text-sm text-base-content/70">
Mettez à jour les informations du composant et ses champs personnalisés.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/component-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
</div>
</div>
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<div
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Sélections du squelette</h2>
<p class="text-xs text-base-content/70">
Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.
</p>
</header>
<div v-if="pieceSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in pieceSlotEntries"
:key="`piece-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<div class="flex items-start gap-2">
<div class="flex-1">
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
</div>
</div>
</div>
</div>
</div>
<div v-if="productSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in productSlotEntries"
:key="`product-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in subcomponentSlotEntries"
:key="`sub-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à ce composant.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="composant"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchComponent()"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="async () => { await submitEdition(); versionRefreshKey++ }">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="composant"
:entity-id="String(route.params.id)"
:entity-name="component?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute } from '#imports'
import { useComponentEdit } from '~/composables/useComponentEdit'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
const route = useRoute()
const { updateDocument } = useDocuments()
const { getConstructeurById } = useConstructeurs()
const versionRefreshKey = ref(0)
const {
component,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
componentDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
constructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
historyFieldLabels,
canEdit,
canSubmit,
componentTypeList,
selectedType,
selectedTypeStructure,
structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
submitEdition,
setSlotQuantity,
setPieceSlotSelection,
setProductSlotSelection,
setSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
fetchComponent,
} = useComponentEdit(String(route.params.id))
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => editionForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
// Remove links whose ID was removed from the select
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = componentDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
componentDocuments.value[idx] = { ...componentDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
</script>

View File

@@ -18,19 +18,13 @@
<p class="text-sm text-base-content/70">Chargement du composant</p>
</div>
<div v-else-if="!component" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Composant introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<EmptyState
v-else-if="!component"
title="Composant introuvable"
description="Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé."
action-label="Retour au catalogue"
action-to="/catalogues/composants"
/>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
@@ -39,383 +33,435 @@
:subtitle="isEditMode ? 'Ajustez les informations du composant et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/component-catalog"
back-link="/catalogues/composants"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<template v-if="isEditMode">
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/component-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ selectedType?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.name }}
</div>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || component.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ component.description }}
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || component.reference || constructeurLinks.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || component.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.reference }}
</div>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || component.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.prix }}
</div>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<!-- Skeleton slot selections -->
<div
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">{{ isEditMode ? 'Sélections du squelette' : 'Structure du composant' }}</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.' : 'Pièces, produits et sous-composants associés à ce composant.' }}
</p>
</header>
<div v-if="pieceSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in pieceSlotEntries"
:key="`piece-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<div class="flex items-start gap-2">
<div class="flex-1">
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections composant">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<template v-if="isEditMode">
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/component-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ selectedType?.name || '' }}
</p>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ component.name }}
</p>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || component.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ component.description }}
</div>
</div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="component.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<p class="text-sm font-medium text-base-content py-1 flex items-center gap-2">
<span class="font-mono font-semibold">{{ component.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</p>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || component.reference || constructeurLinks.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || component.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ component.reference }}
</p>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || component.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif (€)</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ component.prix }} €
</p>
</div>
</div>
<UsedInSection entity-type="composants" :entity-id="component?.id ?? null" />
</div>
</template>
<template #tab-structure>
<div class="space-y-6">
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<!-- Skeleton slot selections -->
<div
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">{{ isEditMode ? 'Sélections du squelette' : 'Structure du composant' }}</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.' : 'Pièces, produits et sous-composants associés à ce composant.' }}
</p>
</header>
<div v-if="pieceSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in pieceSlotEntries"
:key="`piece-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<div class="flex items-start gap-2">
<div class="flex-1">
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
</div>
</div>
</template>
<div v-else class="text-sm font-medium py-1 px-2 rounded" :class="slot.isEmpty ? 'border border-error bg-error/10 text-error font-semibold' : 'text-base-content'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>
{{ slot.selectedPieceName }}
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
</template>
</div>
</div>
</div>
</template>
<div v-else class="input input-bordered input-sm md:input-md flex items-center gap-2" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>
{{ slot.selectedPieceName }}
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
</template>
</div>
</div>
</div>
</div>
<div v-if="productSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in productSlotEntries"
:key="`product-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedProductName }}</template>
<div v-if="productSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in productSlotEntries"
:key="`product-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="text-sm font-medium py-1 px-2 rounded" :class="slot.isEmpty ? 'border border-error bg-error/10 text-error font-semibold' : 'text-base-content'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedProductName }}</template>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in subcomponentSlotEntries"
:key="`sub-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedComponentName }}</template>
</div>
</div>
</div>
</div>
</div>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in subcomponentSlotEntries"
:key="`sub-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="text-sm font-medium py-1 px-2 rounded" :class="slot.isEmpty ? 'border border-error bg-error/10 text-error font-semibold' : 'text-base-content'">
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
<template v-else>{{ slot.selectedComponentName }}</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || componentDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à ce composant.' : 'Documents associés à ce composant.' }}
</p>
<template #tab-documents>
<!-- Documents -->
<div
v-if="isEditMode || componentDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à ce composant.' : 'Documents associés à ce composant.' }}
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
/>
</template>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
</template>
<template #tab-custom-fields>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<p class="text-sm font-medium text-base-content py-1">
{{ field.value }}
</p>
</div>
</div>
</template>
</div>
</template>
<template #tab-history>
<div class="space-y-6">
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="composant"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchComponent()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="composant"
:entity-id="String(route.params.id)"
:entity-name="component?.name"
show-resolved
/>
</div>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
/>
</template>
</div>
</EntityTabs>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Save buttons (edit mode only) -->
<!-- Save/Cancel buttons (outside tabs) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
Annuler
@@ -425,16 +471,9 @@
Enregistrer les modifications
</button>
</div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="composant"
:entity-id="String(route.params.id)"
:entity-name="component?.name"
show-resolved
/>
</div>
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
</section>
</main>
@@ -456,6 +495,12 @@ const { getConstructeurById } = useConstructeurs()
const { updateDocument } = useDocuments()
const isEditMode = ref(false)
const versionRefreshKey = ref(0)
const activeTab = ref((route.query.tab as string) || 'general')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const {
component,
@@ -472,6 +517,7 @@ const {
constructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
requiredCustomFieldsFilled,
historyFieldLabels,
canSubmit,
componentTypeList,
@@ -499,11 +545,14 @@ const {
formatStructurePreview,
} = useComponentEdit(String(route.params.id))
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const submitEdition = async () => {
await _submitEdition()
if (!saving.value) {
await fetchComponent()
isEditMode.value = false
versionRefreshKey.value++
}
}
@@ -537,6 +586,14 @@ const visibleCustomFields = computed(() => {
)
})
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'structure', label: 'Structure', count: pieceSlotEntries.value.length + productSlotEntries.value.length + subcomponentSlotEntries.value.length },
{ key: 'documents', label: 'Documents', count: componentDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: visibleCustomFields.value.length },
{ key: 'history', label: 'Historique' },
])
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true

View File

@@ -1,212 +1,243 @@
<template>
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouvel composant</h1>
<p class="text-sm text-base-content/70">
Sélectionnez la catégorie cible puis complétez les informations du composant.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<main class="container mx-auto px-6 py-10">
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="componentTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
<DetailHeader
title="Nouveau composant"
subtitle="Sélectionnez la catégorie cible puis complétez les informations du composant."
:is-edit-mode="false"
:can-edit="false"
back-link="/catalogues/composants"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections composant">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="componentTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<!-- Nom -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div
v-if="structureHasRequirements"
class="space-y-4 rounded-lg border border-primary/30 bg-primary/5 p-4"
>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">
Sélection des éléments du squelette
</h2>
<p class="text-xs text-base-content/70">
Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
<!-- Description -->
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<!-- Référence + Fournisseurs -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<!-- Prix -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
</div>
</template>
<template #tab-structure>
<div class="space-y-6">
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<div
v-if="structureHasRequirements"
class="space-y-4 rounded-lg border border-primary/30 bg-primary/5 p-4"
>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">
Sélection des éléments du squelette
</h2>
<p class="text-xs text-base-content/70">
Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
</p>
</div>
<span
class="badge"
:class="structureSelectionsComplete ? 'badge-success' : structureDataLoading ? 'badge-info' : 'badge-warning'"
>
{{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
</span>
</div>
<div
v-if="structureDataLoading"
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
>
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Chargement du catalogue de pièces, produits et composants
</div>
<ComponentStructureAssignmentNode
v-else-if="structureAssignments"
:assignment="structureAssignments"
:pieces="availablePieces"
:products="availableProducts"
:components="availableComponents"
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:components-loading="componentsLoading"
:piece-type-label-map="pieceTypeLabelMap"
:product-type-label-map="productTypeLabelMap"
:component-type-label-map="componentTypeLabelMap"
/>
<p v-else class="text-xs text-error">
Impossible de générer les emplacements définis par le squelette.
</p>
</div>
<EmptyState
v-if="!selectedType"
title="Aucune catégorie sélectionnée"
description="Sélectionnez une catégorie dans l'onglet Général pour voir la structure du squelette."
/>
</div>
</template>
<template #tab-documents>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes) liés à ce composant.
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
<span
class="badge"
:class="structureSelectionsComplete ? 'badge-success' : structureDataLoading ? 'badge-info' : 'badge-warning'"
>
{{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
</span>
</div>
</template>
<div
v-if="structureDataLoading"
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
>
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Chargement du catalogue de pièces, produits et composants
</div>
<ComponentStructureAssignmentNode
v-else-if="structureAssignments"
:assignment="structureAssignments"
:pieces="availablePieces"
:products="availableProducts"
:components="availableComponents"
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:components-loading="componentsLoading"
:piece-type-label-map="pieceTypeLabelMap"
:product-type-label-map="productTypeLabelMap"
:component-type-label-map="componentTypeLabelMap"
/>
<p v-else class="text-xs text-error">
Impossible de générer les emplacements définis par le squelette.
</p>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce composant selon le squelette choisi.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes) liés à ce composant.
<template #tab-custom-fields>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce composant selon le squelette choisi.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
<EmptyState
v-else
title="Aucun champ personnalisé"
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
</template>
</EntityTabs>
<!-- Save/Cancel buttons -->
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
<NuxtLink to="/catalogues/composants" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
@@ -214,17 +245,23 @@
Créer le composant
</button>
</div>
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires avant de créer le composant.
</p>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
const route = useRoute()
const { getConstructeurById } = useConstructeurs()
const activeTab = ref('general')
const {
selectedTypeId,
submitting,
@@ -259,8 +296,18 @@ const {
resolveProductLabel,
resolveSubcomponentLabel,
submitCreation,
requiredCustomFieldsFilled,
} = useComponentCreate()
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'structure', label: 'Structure' },
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
])
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.constructeurIds,

View File

@@ -48,6 +48,39 @@
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
</template>
<template #cell-composantCount="{ row }">
<NuxtLink
v-if="stats[row.id]?.composantCount"
:to="`/catalogues/composants?constructeur=${row.id}`"
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
>
{{ stats[row.id].composantCount }}
</NuxtLink>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-pieceCount="{ row }">
<NuxtLink
v-if="stats[row.id]?.pieceCount"
:to="`/catalogues/pieces?constructeur=${row.id}`"
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
>
{{ stats[row.id].pieceCount }}
</NuxtLink>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-machineCount="{ row }">
<NuxtLink
v-if="stats[row.id]?.machineCount"
:to="`/machines?constructeur=${row.id}`"
class="badge badge-ghost badge-sm hover:badge-primary transition-colors"
>
{{ stats[row.id].machineCount }}
</NuxtLink>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
@@ -91,7 +124,7 @@
</main>
</template>
<script setup>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
@@ -103,6 +136,7 @@ import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
const api = useApi()
const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
const { showError } = useToast()
@@ -112,12 +146,16 @@ const columns = [
{ key: 'email', label: 'Email', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{ key: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'composantCount', label: 'Composants', align: 'center' },
{ key: 'pieceCount', label: 'Pièces', align: 'center' },
{ key: 'machineCount', label: 'Machines', align: 'center' },
{ key: 'actions', label: 'Actions', align: 'right' },
]
const searchTerm = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name')
const sortDir = ref('asc')
const stats = ref({})
const currentSort = computed(() => ({
field: sortKey.value,
@@ -236,5 +274,15 @@ const confirmDelete = async (constructeur) => {
}
}
onMounted(() => loadConstructeurs())
const loadStats = async () => {
const result = await api.get('/constructeurs/stats')
if (result.success && result.data) {
stats.value = result.data
}
}
onMounted(() => {
loadConstructeurs()
loadStats()
})
</script>

1000
frontend/app/pages/doc.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -68,34 +68,22 @@
</div>
<!-- Empty State -->
<div v-else-if="filteredSites.length === 0" class="text-center py-16">
<div class="max-w-sm mx-auto">
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<IconLucideFactory
class="w-8 h-8 text-base-content/30"
aria-hidden="true"
/>
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">
Aucune machine trouvée
</h3>
<p class="text-sm text-base-content/50 mb-6">
Commencez par ajouter des sites et des machines.
</p>
<div class="flex gap-2 justify-center">
<button v-if="canEdit" class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site
</button>
<button
v-if="canEdit"
class="btn btn-ghost btn-sm"
@click="showAddMachineModal = true"
>
Ajouter une machine
</button>
</div>
<EmptyState
v-else-if="filteredSites.length === 0"
:icon="IconLucideFactory"
title="Aucune machine trouvée"
description="Commencez par ajouter des sites et des machines."
class="py-16"
>
<div v-if="canEdit" class="flex gap-2 justify-center">
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site
</button>
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
Ajouter une machine
</button>
</div>
</div>
</EmptyState>
<!-- Sites List -->
<div v-else class="space-y-5">
@@ -141,13 +129,14 @@
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class="badge font-bold"
<NuxtLink
:to="`/machines?sites=${site.id}`"
class="badge font-bold hover:opacity-80 transition-opacity"
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
:class="!site.color ? 'badge-primary' : ''"
>
{{ site.machines?.length || 0 }}
</span>
{{ site.machines?.length || 0 }} machine{{ (site.machines?.length || 0) > 1 ? 's' : '' }}
</NuxtLink>
<button
class="btn btn-ghost btn-xs btn-circle"
@click="toggleSiteCollapse(site.id)"

View File

@@ -17,123 +17,129 @@
<!-- Header with actions -->
<MachineDetailHeader
:title="machineViewTitle"
:title="d.machine.value.name"
:description="d.machine.value.description"
:site-name="d.machine.value.site?.name"
:site-color="d.machine.value.site?.color"
:reference="d.machine.value.reference"
:is-edit-mode="d.isEditMode.value"
@toggle-edit="d.toggleEditMode"
@open-print="d.openPrintModal"
/>
<!-- Debug info -->
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
<p>Components count: {{ d.components.value.length }}</p>
<p>Pieces count: {{ d.pieces.value.length }}</p>
</div>
<!-- Hero -->
<PageHero
:title="d.machine.value.name"
:subtitle="d.machine.value.description"
min-height="min-h-[20vh]"
max-width="max-w-md"
rounded
>
<div class="flex justify-center gap-4">
<div
v-if="d.machine.value.site?.name"
class="badge badge-outline font-semibold"
:style="d.machine.value.site?.color ? { borderColor: d.machine.value.site.color + '60', backgroundColor: d.machine.value.site.color + '25', color: d.machine.value.site.color } : {}"
>
{{ d.machine.value.site?.name }}
<!-- Tabbed content -->
<EntityTabs v-model="activeTab" :tabs="machineTabs" aria-label="Sections machine">
<template #tab-general>
<div class="space-y-8">
<MachineInfoCard
ref="machineInfoCardRef"
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-site-id="d.machineSiteId.value"
:machine-site-name="d.machine.value?.site?.name ?? ''"
:sites="d.sites.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:constructeur-links="d.constructeurLinks.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
:machine-id="machineId"
:machine-custom-field-defs="d.machine.value?.customFields ?? []"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:machine-site-id="d.machineSiteId.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@update:constructeur-links="d.constructeurLinks.value = $event"
@remove-constructeur-link="handleRemoveConstructeurLink"
@set-custom-field-value="d.setMachineCustomFieldValue"
@custom-fields-saved="() => { if (!isSavingMachine) { d.loadMachineData(); refreshVersions() } }"
/>
<MachineProductsCard
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
:products="d.machineDirectProducts.value"
:is-edit-mode="d.isEditMode.value"
@add-product="openAddModal('product')"
@remove-product="confirmRemoveProduct"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'product', typeId)"
/>
</div>
<div v-if="d.machine.value.reference" class="badge badge-outline">
{{ d.machine.value.reference }}
</template>
<template #tab-structure>
<div class="space-y-8">
<MachineComponentsCard
v-if="d.isEditMode.value || d.components.value.length > 0"
:components="d.components.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value"
:collapse-toggle-token="d.collapseToggleToken.value"
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.handleCustomFieldUpdate"
@add-component="openAddModal('component')"
@remove-component="confirmRemoveComponent"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'component', typeId)"
/>
<MachinePiecesCard
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
:pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.piecesCollapsed.value"
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.handleCustomFieldUpdate"
@add-piece="openAddModal('piece')"
@remove-piece="confirmRemovePiece"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'piece', typeId)"
@toggle-collapse="d.toggleAllPieces"
/>
</div>
</div>
</PageHero>
</template>
<!-- Machine Info Card -->
<MachineInfoCard
ref="machineInfoCardRef"
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-site-id="d.machineSiteId.value"
:machine-site-name="d.machine.value?.site?.name ?? ''"
:sites="d.sites.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:constructeur-links="d.constructeurLinks.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
:machine-id="machineId"
:machine-custom-field-defs="d.machine.value?.customFields ?? []"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:machine-site-id="d.machineSiteId.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@update:constructeur-links="d.constructeurLinks.value = $event"
@remove-constructeur-link="handleRemoveConstructeurLink"
@set-custom-field-value="d.setMachineCustomFieldValue"
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
/>
<template #tab-documents>
<MachineDocumentsCard
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
:documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value"
:files="d.machineDocumentFiles.value"
@update:files="d.machineDocumentFiles.value = $event"
@files-added="d.handleMachineFilesAdded"
@preview="d.openPreview"
@download="d.downloadDocument"
@remove="confirmRemoveDocument"
/>
</template>
<!-- Documents -->
<MachineDocumentsCard
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
:documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value"
:files="d.machineDocumentFiles.value"
@update:files="d.machineDocumentFiles.value = $event"
@files-added="d.handleMachineFilesAdded"
@preview="d.openPreview"
@download="d.downloadDocument"
@remove="d.removeMachineDocument"
/>
<!-- Produits associés -->
<MachineProductsCard
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
:products="d.machineDirectProducts.value"
:is-edit-mode="d.isEditMode.value"
@add-product="openAddModal('product')"
@remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'product', typeId)"
/>
<!-- Components Section -->
<MachineComponentsCard
v-if="d.isEditMode.value || d.components.value.length > 0"
:components="d.components.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value"
:collapse-toggle-token="d.collapseToggleToken.value"
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.handleCustomFieldUpdate"
@add-component="openAddModal('component')"
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'component', typeId)"
/>
<!-- Machine Pieces Section -->
<MachinePiecesCard
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
:pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.piecesCollapsed.value"
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.handleCustomFieldUpdate"
@add-piece="openAddModal('piece')"
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'piece', typeId)"
@toggle-collapse="d.toggleAllPieces"
/>
<template #tab-history>
<div class="space-y-8">
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
ref="versionListRef"
entity-type="machine"
:entity-id="String(machineId)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="d.loadMachineData()"
/>
<CommentSection
entity-type="machine"
:entity-id="String(machineId)"
:entity-name="d.machine.value?.name"
show-resolved
/>
</div>
</template>
</EntityTabs>
<!-- Add Entity Modal -->
<AddEntityToMachineModal
@@ -164,50 +170,17 @@
Enregistrer les modifications
</button>
</div>
<!-- Historique -->
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Versions -->
<EntityVersionList
ref="versionListRef"
entity-type="machine"
:entity-id="String(machineId)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="d.loadMachineData()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="machine"
:entity-id="String(machineId)"
:entity-name="d.machine.value?.name"
show-resolved
/>
</div>
</div>
<!-- Error State -->
<div v-else class="text-center py-12">
<div class="max-w-md mx-auto">
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<IconLucideAlertTriangle class="w-8 h-8 text-base-content/30" aria-hidden="true" />
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">Machine non trouvée</h3>
<p class="text-sm text-base-content/50 mb-6">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p>
<button type="button" class="btn btn-primary" @click="$router.back()">
Retour aux machines
</button>
</div>
</div>
<EmptyState
v-else
:icon="IconLucideAlertTriangle"
title="Machine non trouvée"
:description="`La machine avec l'ID « ${machineId} » n'existe pas ou a été supprimée.`"
action-label="Retour aux machines"
@action="$router.back()"
/>
</main>
<MachinePrintSelectionModal
@@ -223,13 +196,12 @@
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMachineDetailData } from '~/composables/useMachineDetailData'
import { useEntityHistory } from '~/composables/useEntityHistory'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import PageHero from '~/components/PageHero.vue'
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
import MachineDetailHeader from '~/components/machine/MachineDetailHeader.vue'
import MachineInfoCard from '~/components/machine/MachineInfoCard.vue'
@@ -251,9 +223,24 @@ if (!machineId) {
}
const d = useMachineDetailData(machineId)
const machineInfoCardRef = ref(null)
const machineInfoCardRef = ref<{ saveFieldDefinitions?: () => Promise<void> } | null>(null)
const versionRefreshKey = ref(0)
const refreshVersions = () => { versionRefreshKey.value++ }
const isSavingMachine = ref(false)
const { confirm: confirmDialog } = useConfirm()
const versionListRef = ref<InstanceType<typeof EntityVersionList> | null>(null)
const activeTab = ref((route.query.tab as string) || 'general')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const machineTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'structure', label: 'Structure', count: d.components.value.length + d.machinePieces.value.length },
{ key: 'documents', label: 'Documents', count: d.machineDocumentsList.value.length },
{ key: 'history', label: 'Historique' },
])
const {
history,
@@ -321,25 +308,49 @@ const handleAddEntity = async (payload) => {
refreshVersions()
}
const handleFillEntity = (linkId, entityKind, modelTypeId) => {
const handleFillEntity = (linkId: string, entityKind: string, modelTypeId: string) => {
fillLinkId.value = linkId
fillTypeId.value = modelTypeId
addModalKind.value = entityKind
addModalOpen.value = true
}
const machineViewTitle = computed(() => {
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
})
const submitMachineEdition = async () => {
if (machineInfoCardRef.value?.saveFieldDefinitions) {
await machineInfoCardRef.value.saveFieldDefinitions()
isSavingMachine.value = true
try {
if (machineInfoCardRef.value?.saveFieldDefinitions) {
await machineInfoCardRef.value.saveFieldDefinitions()
}
await d.submitEdition()
refreshVersions()
} finally {
isSavingMachine.value = false
}
await d.submitEdition()
}
const confirmRemoveProduct = async (id: string) => {
if (!await confirmDialog({ title: 'Retirer ce produit ?', message: 'Le produit sera dissocié de la machine.', confirmText: 'Retirer', dangerous: true })) return
await d.removeProductLink(id)
refreshVersions()
}
const confirmRemoveComponent = async (id: string) => {
if (!await confirmDialog({ title: 'Retirer ce composant ?', message: 'Le composant sera dissocié de la machine.', confirmText: 'Retirer', dangerous: true })) return
await d.removeComponentLink(id)
refreshVersions()
}
const confirmRemovePiece = async (id: string) => {
if (!await confirmDialog({ title: 'Retirer cette pièce ?', message: 'La pièce sera dissociée de la machine.', confirmText: 'Retirer', dangerous: true })) return
await d.removePieceLink(id)
refreshVersions()
}
const confirmRemoveDocument = async (id: string) => {
if (!await confirmDialog({ title: 'Supprimer ce document ?', message: 'Le fichier sera supprimé définitivement.', confirmText: 'Supprimer', dangerous: true })) return
await d.removeMachineDocument(id)
}
onMounted(() => {
d.loadMachineData()
d.loadInitialData()

View File

@@ -53,20 +53,14 @@
<span class="loading loading-spinner loading-lg" />
</div>
<div v-else-if="filteredMachines.length === 0" class="text-center py-12">
<div class="max-w-md mx-auto">
<IconLucideFactory class="w-16 h-16 mx-auto text-gray-400 mb-4" aria-hidden="true" />
<h3 class="text-lg font-medium text-gray-900 mb-2">
Aucune machine trouvée
</h3>
<p class="text-gray-500 mb-4">
Commencez par ajouter votre première machine.
</p>
<NuxtLink to="/machines/new" class="btn btn-primary">
Ajouter une machine
</NuxtLink>
</div>
</div>
<EmptyState
v-else-if="filteredMachines.length === 0"
:icon="IconLucideFactory"
title="Aucune machine trouvée"
description="Commencez par ajouter votre première machine."
action-label="Ajouter une machine"
action-to="/machines/new"
/>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div

View File

@@ -18,19 +18,13 @@
<p class="text-sm text-base-content/70">Chargement de la pièce</p>
</div>
<div v-else-if="!piece" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<EmptyState
v-else-if="!piece"
title="Pièce introuvable"
description="Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée."
action-label="Retour au catalogue"
action-to="/catalogues/pieces"
/>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
@@ -39,331 +33,385 @@
:subtitle="isEditMode ? 'Ajustez les informations de la pièce et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/pieces-catalog"
back-link="/catalogues/pieces"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<template v-if="isEditMode">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in pieceTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ selectedType?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.name }}
</div>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || piece.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ piece.description }}
</div>
</div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="piece.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
<EntityTabs
v-model="activeTab"
:tabs="entityTabs"
aria-label="Sections de la pièce"
>
<div v-if="isEditMode || piece.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.reference }}
</div>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<template v-if="isEditMode">
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
<ConstructeurLinksTable
v-model="constructeurLinks"
class="mt-2"
@remove="handleConstructeurRemoved"
/>
</template>
<ConstructeurLinksTable
v-else
:model-value="constructeurLinks"
readonly
/>
</div>
</div>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || piece.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.prix }}
</div>
</div>
</div>
<!-- Product requirements -->
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produits liés
</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div v-if="isEditMode" class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelections[entry.index] }">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || saving"
:type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="(entry, index) in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelectionLabels[index] }">{{ entry.label }}</span>
</label>
<div class="input input-bordered input-sm md:input-md flex items-center" :class="productSelectionLabels[index] ? 'bg-base-200' : 'border-error bg-error/10 text-error font-semibold'">
<template v-if="!productSelectionLabels[index]">{{ entry.label }} — manquant</template>
<template v-else>{{ productSelectionLabels[index] }}</template>
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<template v-if="isEditMode">
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in pieceTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/piece-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ selectedType?.name || '' }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && (selectedType || resolvedStructure)"
:structure="resolvedStructure"
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
variant="piece"
/>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ piece.name }}
</p>
</div>
</div>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || piece.description" class="form-control">
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
<span class="label-text">Description</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
<textarea
v-if="isEditMode"
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ piece.description }}
</div>
</div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="piece.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<p class="text-sm font-medium text-base-content py-1 flex items-center gap-2">
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</p>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || piece.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ piece.reference }}
</p>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<template v-if="isEditMode">
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
<ConstructeurLinksTable
v-model="constructeurLinks"
class="mt-2"
@remove="handleConstructeurRemoved"
/>
</template>
<ConstructeurLinksTable
v-else
:model-value="constructeurLinks"
readonly
/>
</div>
</div>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || piece.prix" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif (€)</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ piece.prix }} €
</p>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && (selectedType || resolvedStructure)"
:structure="resolvedStructure"
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
variant="piece"
/>
<UsedInSection entity-type="pieces" :entity-id="piece?.id ?? null" />
</div>
</template>
<template #tab-products>
<div class="space-y-6">
<!-- Product requirements -->
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produits liés
</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div v-if="isEditMode" class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelections[entry.index] }">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || saving"
:type-product-id="entry.typeProductId"
helper-text="Sélectionnez un produit (optionnel)."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="(entry, index) in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelectionLabels[index] }">{{ entry.label }}</span>
</label>
<div class="text-sm font-medium py-1 px-2 rounded" :class="productSelectionLabels[index] ? 'text-base-content' : 'border border-error bg-error/10 text-error font-semibold'">
<template v-if="!productSelectionLabels[index]">{{ entry.label }} — manquant</template>
<template v-else>{{ productSelectionLabels[index] }}</template>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || pieceDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à cette pièce.' : 'Documents associés à cette pièce.' }}
</p>
<template #tab-documents>
<div class="space-y-6">
<!-- Documents -->
<div
v-if="isEditMode || pieceDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à cette pièce.' : 'Documents associés à cette pièce.' }}
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
/>
</template>
</div>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
</template>
<template #tab-custom-fields>
<div class="space-y-6">
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<p class="text-sm font-medium text-base-content py-1">
{{ field.value }}
</p>
</div>
</div>
</template>
</div>
</div>
</template>
<template #tab-history>
<div class="space-y-6">
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="piece"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchPiece()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece"
:entity-id="String(route.params.id)"
:entity-name="piece?.name"
show-resolved
/>
</div>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
/>
</template>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="piece"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchPiece()"
/>
</EntityTabs>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
@@ -375,16 +423,9 @@
Enregistrer les modifications
</button>
</div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece"
:entity-id="String(route.params.id)"
:entity-name="piece?.name"
show-resolved
/>
</div>
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
</section>
</main>
@@ -405,6 +446,11 @@ const { updateDocument } = useDocuments()
const isEditMode = ref(false)
const versionRefreshKey = ref(0)
const activeTab = ref((route.query.tab as string) || 'general')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const {
piece,
loading,
@@ -420,6 +466,7 @@ const {
constructeurLinks,
productSelections,
customFieldInputs,
requiredCustomFieldsFilled,
pieceTypeList,
selectedType,
resolvedStructure,
@@ -441,6 +488,16 @@ const {
formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id))
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
{ key: 'documents', label: 'Documents', count: pieceDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: visibleCustomFields.value.length },
{ key: 'history', label: 'Historique' },
])
const submitEdition = async () => {
await _submitEdition()
if (!saving.value) {

View File

@@ -1,245 +0,0 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Catalogue des pièces</h1>
<p class="text-sm text-gray-500">
Consultez et gérez toutes les pièces existantes.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/pieces/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter une pièce
</NuxtLink>
<NuxtLink to="/piece-category" class="btn btn-outline btn-sm md:btn-md">
Gérer les catégories
</NuxtLink>
</div>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<header class="flex flex-col gap-2">
<h2 class="text-xl font-semibold text-base-content">Pièces créées</h2>
<p class="text-sm text-base-content/70">
Liste globale des pièces enregistrées, quel que soit leur squelette d'origine.
</p>
</header>
<DataTable
:columns="columns"
:rows="pieceRows"
:loading="loadingPieces"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucune pièce n'a encore été créée."
no-results-message="Aucune pièce ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.piece)"
:alt="resolvePreviewAlt(row.piece)"
/>
</template>
<template #cell-name="{ row }">
{{ row.piece.name || 'Pièce sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.piece.reference || '—' }}
</template>
<template #cell-referenceAuto="{ row }">
{{ row.piece.referenceAuto || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-sm group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
</div>
</div>
<span v-else>—</span>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else>—</span>
</template>
<template #cell-typePiece="{ row }">
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/piece/${row.piece.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>
Supprimer
</button>
<NuxtLink
:to="`/piece/${row.piece.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions()
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const table = useDataTable(
{ fetchData: fetchPieces },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typePiece'] },
)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'referenceAuto', label: 'Réf. auto' },
{ key: 'description', label: 'Description' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const piecesOnPage = computed(() => pieceRows.value.length)
const paginationState = table.pagination(total, piecesOnPage)
// Enrich pieces with full type data
const piecesList = computed(() => {
return (pieces.value || []).map((piece) => {
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
return { ...piece, typePiece: typePiece || piece.typePiece || null }
})
})
const pieceRows = computed(() =>
piecesList.value.map(piece => ({
id: piece.id,
piece,
suppliers: buildPieceSuppliersDisplay(piece),
})),
)
async function fetchPieces() {
await loadPieces({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typePiece || undefined,
force: true,
})
}
const resolvePieceType = (piece: Record<string, any>) => {
if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel
return '—'
}
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm()
const handleDeletePiece = async (piece: Record<string, any>) => {
const pieceName = piece?.name || 'cette pièce'
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
if (!confirmed) return
await deletePiece(piece.id)
fetchPieces()
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchPieces(), loadPieceTypes()])
})
</script>

View File

@@ -1,357 +0,0 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement de la pièce</p>
</div>
<div v-else-if="!piece" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier la pièce</h1>
<p class="text-sm text-base-content/70">
Ajustez les informations de la pièce et ses champs personnalisés.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in pieceTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
</div>
<div v-if="piece?.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<input
:value="piece.referenceAuto"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
title="Générée automatiquement à partir du type et des champs personnalisés"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
</div>
</div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || saving"
:type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
</div>
<StructureSkeletonPreview
v-if="selectedType || resolvedStructure"
:structure="resolvedStructure"
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
variant="piece"
/>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à cette pièce.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece"
:entity-id="String(route.params.id)"
:entity-name="piece?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from '#imports'
import { usePieceEdit } from '~/composables/usePieceEdit'
import { useDocuments } from '~/composables/useDocuments'
const route = useRoute()
const { updateDocument } = useDocuments()
const {
piece,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
pieceDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
productSelections,
customFieldInputs,
canEdit,
pieceTypeList,
selectedType,
resolvedStructure,
structureProducts,
productRequirementDescriptions,
productRequirementEntries,
canSubmit,
historyFieldLabels,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
setProductSelection,
submitEdition,
formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id))
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = pieceDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
pieceDocuments.value[idx] = { ...pieceDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
</script>

View File

@@ -1,206 +1,238 @@
<template>
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouvelle pièce</h1>
<p class="text-sm text-base-content/70">
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<main class="container mx-auto px-6 py-10">
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="pieceTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
<DetailHeader
title="Nouvelle pièce"
subtitle="Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce."
:is-edit-mode="false"
:can-edit="false"
back-link="/catalogues/pieces"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections de la pièce">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="pieceTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || submitting || !selectedType"
:type-product-id="entry.typeProductId"
helper-text="Un produit est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
<!-- Nom -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<!-- Description -->
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<!-- Référence + Fournisseurs -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<!-- Prix -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<!-- Skeleton preview -->
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedType.structure"
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
variant="piece"
/>
</div>
</div>
</div>
</template>
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedType.structure"
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
variant="piece"
/>
<template #tab-products>
<div class="space-y-6">
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || submitting || !selectedType"
:type-product-id="entry.typeProductId"
helper-text="Sélectionnez un produit (optionnel)."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<EmptyState
v-if="!structureProducts.length"
title="Aucun produit requis"
:description="selectedType ? 'Cette catégorie ne requiert pas de produit lié.' : 'Sélectionnez une catégorie pour voir les produits requis.'"
/>
</div>
</template>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes…) liés à cette pièce.
<template #tab-documents>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes…) liés à cette pièce.
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
</div>
</template>
<template #tab-custom-fields>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</div>
<EmptyState
v-else
title="Aucun champ personnalisé"
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
/>
</template>
</EntityTabs>
<!-- Save/Cancel buttons -->
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
<NuxtLink to="/catalogues/pieces" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
@@ -208,6 +240,9 @@
Créer la pièce
</button>
</div>
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires avant de créer la pièce.
</p>
</div>
</section>
</main>
@@ -225,7 +260,6 @@ import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useConstructeurs } from '~/composables/useConstructeurs'
@@ -239,16 +273,10 @@ import {
buildProductRequirementDescriptions,
buildProductRequirementEntries,
resizeProductSelections,
areProductSelectionsFilled,
applyProductSelection,
collectNormalizedProductIds,
} from '~/shared/utils/pieceProductSelectionUtils'
import {
type CustomFieldInput,
normalizeCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null
@@ -261,12 +289,12 @@ const router = useRouter()
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
const { createPiece } = usePieces()
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
const { canEdit } = usePermissions()
const activeTab = ref('general')
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
@@ -281,7 +309,16 @@ const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const productSelections = ref<(string | null)[]>([])
const lastSuggestedName = ref('')
const customFieldInputs = ref<CustomFieldInput[]>([])
const cfDefinitions = ref<any[]>([])
const createdEntityId = ref<string | null>(null)
const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, saveAll: saveAllCustomFields } = useCustomFieldInputs({
definitions: cfDefinitions,
values: [] as any[],
entityType: 'piece' as CustomFieldEntityType,
entityId: createdEntityId,
context: 'standalone',
})
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
@@ -341,13 +378,7 @@ const productRequirementEntries = computed(() =>
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
)
const productSelectionsFilled = computed(() =>
areProductSelectionsFilled(
requiresProductSelection.value,
productRequirementEntries.value,
productSelections.value,
),
)
const productSelectionsFilled = computed(() => true)
const setProductSelection = (index: number, value: string | null) => {
productSelections.value = applyProductSelection(productSelections.value, index, value)
@@ -360,21 +391,17 @@ watch(structureProducts, (products) => {
watch(selectedType, (type) => {
if (!type) {
clearCreationForm()
customFieldInputs.value = []
cfDefinitions.value = []
return
}
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
creationForm.name = type.name
}
lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
cfDefinitions.value = type.structure?.customFields ?? []
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() =>
Boolean(
canEdit.value &&
@@ -386,6 +413,13 @@ const canSubmit = computed(() =>
),
)
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
])
const clearCreationForm = () => {
creationForm.name = ''
creationForm.description = ''
@@ -403,11 +437,6 @@ const submitCreation = async () => {
return
}
if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const payload: Record<string, any> = {
name: creationForm.name.trim(),
typePieceId: selectedType.value.id,
@@ -450,14 +479,11 @@ const submitCreation = async () => {
const result = await createPiece(payload)
if (result.success && result.data) {
const createdPiece = result.data as Record<string, any>
await _saveCustomFieldValues(
'piece',
createdPiece.id,
[
createdPiece?.typePiece?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
createdEntityId.value = createdPiece.id
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Pièce créée, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
}
// Sync constructeur links after creation
if (constructeurLinks.value.length) {
await syncLinks('piece', createdPiece.id, [], constructeurLinks.value)
@@ -515,5 +541,4 @@ watch(
onMounted(async () => {
await loadPieceTypes()
})
</script>

View File

@@ -1,257 +0,0 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Catalogue des produits</h1>
<p class="text-sm text-base-content/70">
Retrouvez l'ensemble des produits du catalogue, leurs informations fournisseurs et leurs catégories.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/product/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un produit
</NuxtLink>
<NuxtLink to="/product-category" class="btn btn-outline btn-sm md:btn-md">
Gérer les catégories
</NuxtLink>
</div>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div
v-if="errorMessage"
class="alert alert-error"
>
<div class="flex flex-col gap-1">
<span class="font-semibold">Impossible de charger les produits</span>
<span class="text-sm">{{ errorMessage }}</span>
</div>
<button type="button" class="btn btn-ghost btn-sm ml-auto" @click="reload">
Réessayer
</button>
</div>
<DataTable
v-else
:columns="columns"
:rows="productRows"
:loading="loading"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun produit n'a encore été enregistré."
no-results-message="Aucun produit ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="table.searchTerm.value"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence"
@input="table.debouncedSearch"
/>
</label>
</template>
<template #cell-preview="{ row }">
<DocumentThumbnail
:document="resolvePrimaryDocument(row.product, true)"
:alt="resolvePreviewAlt(row.product)"
/>
</template>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.product.name }}</span>
</template>
<template #cell-reference="{ row }">
{{ row.product.reference || '—' }}
</template>
<template #cell-typeProduct="{ row }">
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else class="text-sm text-base-content/50">—</span>
</template>
<template #cell-price="{ row }">
{{ formatPrice(row.product.supplierPrice) }}
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/product/${row.product.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)"
>
Supprimer
</button>
<NuxtLink
:to="`/product/${row.product.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useHead } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
const { canEdit } = usePermissions()
useHead(() => ({ title: 'Catalogue des produits' }))
const {
products,
total,
loading,
error,
loadProducts,
deleteProduct,
} = useProducts()
const { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast()
const table = useDataTable(
{ fetchData: fetchProducts },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeProduct'] },
)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
]
const productsOnPage = computed(() => productRows.value.length)
const paginationState = table.pagination(total, productsOnPage)
// Enrich products with full type data
const normalizedProducts = computed(() => {
return (Array.isArray(products.value) ? products.value : []).map((product) => {
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
})
})
const productRows = computed(() =>
normalizedProducts.value.map(product => ({
id: product.id,
product,
suppliers: buildProductSuppliersDisplay(product),
})),
)
async function fetchProducts() {
await loadProducts({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
typeName: table.columnFilters.value.typeProduct || undefined,
force: true,
})
}
const priceFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
const formatPrice = (value: any) => {
if (value === null || value === undefined || value === '') return '—'
const number = Number(value)
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
}
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(product))
const reload = () => fetchProducts()
const { confirm } = useConfirm()
const confirmDelete = async (product: Record<string, any>) => {
const productName = product?.name || 'ce produit'
const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
if (!confirmed) return
const result = await deleteProduct(product.id)
if (result.success) {
toast.showSuccess(`Produit "${productName}" supprimé`)
}
}
onMounted(async () => {
await Promise.all([fetchProducts(), loadProductTypes()])
})
</script>

View File

@@ -1,564 +0,0 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="productDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du produit</p>
</div>
<div v-else-if="!product" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Produit introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier le produit</h1>
<p class="text-sm text-base-content/70">
Mettez à jour les informations du produit et ses champs personnalisés.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<input
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []"
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
>
</div>
</div>
<div v-if="structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
<p class="text-xs text-base-content/70">
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
</p>
</div>
<span class="badge badge-outline">{{ structurePreview }}</span>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à ce produit.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="product"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="loadProduct()"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product"
:entity-id="String(route.params.id)"
:entity-name="product?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useProductHistory } from '~/composables/useProductHistory'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
const { canEdit } = usePermissions()
const versionRefreshKey = ref(0)
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument,
updateDocument,
} = useDocuments()
const { ensureConstructeurs, getConstructeurById } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useProductHistory()
const product = ref<any | null>(null)
const productType = ref<any | null>(null)
const structure = ref<ProductModelStructure | null>(null)
const customFieldInputs = ref<CustomFieldInput[]>([])
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
supplierPrice: 'Prix fournisseur',
typeProduct: 'Catégorie',
constructeurIds: 'Fournisseurs',
}
const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null,
) => {
const nextStructure = structureOverride ?? structure.value ?? null
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
}
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() =>
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
)
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = productDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
productDocuments.value[idx] = { ...productDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
const loadProduct = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
product.value = null
loading.value = false
return
}
const result = await getProduct(id)
if (result.success && result.data) {
product.value = result.data
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
await loadProductType()
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
hydrateForm()
// History is non-blocking — template handles its own loading state
loadHistory(result.data.id).catch(() => {})
} else {
product.value = null
}
loading.value = false
}
const refreshDocuments = async () => {
if (!product.value?.id) {
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByProduct(product.value.id, { updateStore: false })
if (result.success) {
productDocuments.value = Array.isArray(result.data) ? result.data : []
}
} finally {
loadingDocuments.value = false
}
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteProductDocument(documentId, { updateStore: false })
if (result.success) {
productDocuments.value = productDocuments.value.filter((doc) => doc.id !== documentId)
toast.showSuccess('Document supprimé')
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !product.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadProductDocuments(
{
files,
context: { productId: product.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
toast.showSuccess('Document(s) ajouté(s)')
} else if (result.error) {
toast.showError(result.error)
}
} finally {
uploadingDocuments.value = false
}
}
const loadProductType = async () => {
// Try using the expanded typeProduct from entity response first
const embedded = product.value?.typeProduct
if (embedded && typeof embedded === 'object' && embedded.id) {
const embeddedStructure = embedded.structure ?? null
if (embeddedStructure) {
productType.value = embedded
structure.value = normalizeProductStructureForSave(embeddedStructure)
return
}
}
if (!product.value?.typeProductId) {
productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
return
}
try {
const type = await getModelType(product.value.typeProductId)
productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? null)
} catch (error) {
console.error('Erreur lors du chargement du type de produit:', error)
productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
}
}
const hydrateForm = () => {
if (!product.value) {
return
}
editionForm.name = product.value.name || ''
editionForm.reference = product.value.reference || ''
// Load constructeur links
fetchLinks('product', String(route.params.id)).then((links) => {
constructeurLinks.value = links
originalConstructeurLinks.value = links.map(l => ({ ...l }))
editionForm.constructeurIds = constructeurIdsFromLinks(links)
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
})
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
? String(product.value.supplierPrice)
: ''
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
}
watch(
() => product.value?.documents,
(docs) => {
if (Array.isArray(docs)) {
productDocuments.value = docs
}
},
{ immediate: true },
)
const submitEdition = async () => {
if (!product.value) {
return
}
const payload: Record<string, any> = {
name: editionForm.name.trim(),
reference: editionForm.reference.trim() || null,
}
const rawPrice = typeof editionForm.supplierPrice === 'string'
? editionForm.supplierPrice.trim()
: editionForm.supplierPrice
payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
? Number.isNaN(Number(rawPrice))
? null
: String(Number(rawPrice))
: null
saving.value = true
try {
const result = await updateProduct(product.value.id, payload)
if (result.success && result.data?.id) {
product.value = result.data
const failedFields = await _saveCustomFieldValues(
'product',
result.data.id,
[result.data?.typeProduct?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return
}
await syncLinks('product', product.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
toast.showSuccess('Produit mis à jour avec succès')
versionRefreshKey.value++
}
} catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
} finally {
saving.value = false
}
}
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => editionForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
onMounted(async () => {
await loadProduct()
})
</script>

View File

@@ -39,239 +39,297 @@
:subtitle="isEditMode ? 'Ajustez les informations du produit et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/product-catalog"
back-link="/catalogues/produits"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<template v-if="isEditMode">
<input
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product?.typeProduct?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.name }}
</div>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || product.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
<EntityTabs
v-model="activeTab"
:tabs="entityTabs"
aria-label="Sections du produit"
>
<div v-if="isEditMode || product.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.reference }}
</div>
</div>
<div v-if="isEditMode || editionForm.constructeurIds.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []"
/>
<div v-else class="flex flex-wrap gap-2">
<span
v-for="id in editionForm.constructeurIds"
:key="id"
class="badge badge-outline"
>
{{ getConstructeurById(id)?.name || id }}
</span>
</div>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix fournisseur (if value or edit mode) -->
<div v-if="isEditMode || product.supplierPrice" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.supplierPrice }}
</div>
</div>
</div>
<!-- Structure preview (edit mode only) -->
<div v-if="isEditMode && structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
<p class="text-xs text-base-content/70">
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
</p>
</div>
<span class="badge badge-outline">{{ structurePreview }}</span>
</div>
</div>
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<template v-if="isEditMode">
<div class="flex items-center gap-2">
<input
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200 flex-1"
disabled
>
<NuxtLink
v-if="product?.typeProduct?.id"
:to="`/product-category/${product.typeProduct.id}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</template>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ product?.typeProduct?.name || '—' }}
</p>
</div>
</div>
<!-- Nom (always shown) -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ product.name }}
</p>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || product.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || product.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ product.reference }}
</p>
</div>
<div v-if="isEditMode || editionForm.constructeurIds.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []"
/>
<div v-else class="flex flex-wrap gap-2">
<span
v-for="id in editionForm.constructeurIds"
:key="id"
class="badge badge-outline"
>
{{ getConstructeurById(id)?.name || id }}
</span>
</div>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix fournisseur (if value or edit mode) -->
<div v-if="isEditMode || product.supplierPrice" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-if="isEditMode"
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<p v-else class="text-sm font-medium text-base-content py-1">
{{ product.supplierPrice }}
</p>
</div>
</div>
<!-- Structure preview (edit mode only) -->
<div v-if="isEditMode && structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
<p class="text-xs text-base-content/70">
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
</p>
</div>
<span class="badge badge-outline">{{ structurePreview }}</span>
</div>
</div>
<UsedInSection entity-type="products" :entity-id="product?.id ?? null" />
</div>
</template>
<template #tab-documents>
<div class="space-y-6">
<!-- Documents -->
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à ce produit.' : 'Documents associés à ce produit.' }}
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
/>
</template>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || productDocuments.length > 0"
class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Gérez les documents associés à ce produit.' : 'Documents associés à ce produit.' }}
<template #tab-custom-fields>
<div class="space-y-6">
<!-- Custom fields -->
<div v-if="visibleCustomFields.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.customFieldId || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<p class="text-sm font-medium text-base-content py-1">
{{ field.value }}
</p>
</div>
</div>
</template>
</div>
<p v-else class="text-sm text-base-content/60">
Aucun champ personnalisé n'est défini pour ce produit.
</p>
</div>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<template v-if="isEditMode">
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
/>
</template>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<template #tab-history>
<div class="space-y-6">
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="product"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="loadProduct()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product"
:entity-id="String(route.params.id)"
:entity-name="product?.name"
show-resolved
/>
</div>
</div>
</template>
</EntityTabs>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
@@ -283,19 +341,9 @@
Enregistrer les modifications
</button>
</div>
<p v-if="isEditMode && product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
<p v-if="isEditMode && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product"
:entity-id="String(route.params.id)"
:entity-name="product?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
@@ -304,18 +352,17 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import { navigateTo, useRoute } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useProductHistory } from '~/composables/useProductHistory'
import { useEntityHistory } from '~/composables/useEntityHistory'
import { usePermissions } from '~/composables/usePermissions'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
@@ -323,19 +370,12 @@ import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
@@ -349,9 +389,10 @@ const {
loading: historyLoading,
error: historyError,
loadHistory,
} = useProductHistory()
} = useEntityHistory('product')
const isEditMode = ref(false)
const versionRefreshKey = ref(0)
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
@@ -359,7 +400,20 @@ const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const product = ref<any | null>(null)
const productType = ref<any | null>(null)
const structure = ref<ProductModelStructure | null>(null)
const customFieldInputs = ref<CustomFieldInput[]>([])
const cfDefinitions = ref<any[]>([])
const cfValues = ref<any[]>([])
const entityId = computed(() => product.value?.id ?? null)
const {
fields: customFieldInputs,
requiredFilled: requiredCustomFieldsFilled,
saveAll: saveAllCustomFields,
} = useCustomFieldInputs({
definitions: cfDefinitions,
values: cfValues,
entityType: 'product' as CustomFieldEntityType,
entityId,
context: 'standalone',
})
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
@@ -385,7 +439,8 @@ const refreshCustomFieldInputs = (
) => {
const nextStructure = structureOverride ?? structure.value ?? null
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
cfDefinitions.value = nextStructure?.customFields ?? []
cfValues.value = Array.isArray(nextValues) ? nextValues : []
}
const editionForm = reactive({
@@ -395,9 +450,7 @@ const editionForm = reactive({
supplierPrice: '' as string,
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const canSubmit = computed(() =>
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
@@ -412,6 +465,18 @@ const visibleCustomFields = computed(() => {
)
})
const activeTab = ref((route.query.tab as string) || 'general')
watch(activeTab, (val) => {
navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
})
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'documents', label: 'Documents', count: productDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: visibleCustomFields.value.length },
{ key: 'history', label: 'Historique' },
])
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) return
previewDocument.value = doc
@@ -595,12 +660,7 @@ const submitEdition = async () => {
const result = await updateProduct(product.value.id, payload)
if (result.success && result.data?.id) {
product.value = result.data
const failedFields = await _saveCustomFieldValues(
'product',
result.data.id,
[result.data?.typeProduct?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return
@@ -610,6 +670,7 @@ const submitEdition = async () => {
toast.showSuccess('Produit mis à jour avec succès')
await loadProduct()
isEditMode.value = false
versionRefreshKey.value++
}
} catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')

View File

@@ -1,158 +1,178 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouveau produit</h1>
<p class="text-sm text-base-content/70">
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<main class="container mx-auto px-6 py-10">
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="productTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
<DetailHeader
title="Nouveau produit"
subtitle="Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue."
:is-edit-mode="false"
:can-edit="false"
back-link="/catalogues/produits"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="creationForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections du produit">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="productTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
<!-- Nom -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<!-- Référence + Fournisseurs -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<!-- Prix -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="creationForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<!-- Skeleton preview -->
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
</p>
</div>
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
</div>
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
Cette catégorie ne définit pas encore de champs personnalisés.
</p>
</div>
</div>
</template>
<template #tab-documents>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
</div>
</template>
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
Cette catégorie ne définit pas encore de champs personnalisés.
</p>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce produit catalogue.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
<template #tab-custom-fields>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce produit catalogue.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
<p v-if="hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-warning">
Certains champs personnalisés sont obligatoires. Veuillez les renseigner avant de valider.
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
<EmptyState
v-else
title="Aucun champ personnalisé"
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
</template>
</EntityTabs>
<!-- Save/Cancel buttons -->
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
<NuxtLink to="/catalogues/produits" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
@@ -160,7 +180,7 @@
Créer le produit
</button>
</div>
<p v-if="selectedType && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
<p v-if="selectedType && hasRequiredCustomFields && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
@@ -177,7 +197,6 @@ import DocumentUpload from '~/components/DocumentUpload.vue'
import { useProductTypes } from '~/composables/useProductTypes'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
@@ -186,10 +205,7 @@ import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useConstructeurs } from '~/composables/useConstructeurs'
import type { ProductModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import {
type CustomFieldInput,
normalizeCustomFieldInputs as normalizeCustomFieldInputsFromUtils,
} from '~/shared/utils/customFieldFormUtils'
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
interface ProductCatalogType extends ModelType {
structure: ProductModelStructure | null
@@ -202,12 +218,12 @@ const router = useRouter()
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
const { createProduct } = useProducts()
const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions()
const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
const activeTab = ref('general')
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
@@ -221,7 +237,16 @@ const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
const customFieldInputs = ref<CustomFieldInput[]>([])
const cfDefinitions = ref<any[]>([])
const createdEntityId = ref<string | null>(null)
const { fields: customFieldInputs, requiredFilled: requiredCustomFieldsFilled, saveAll: saveAllCustomFields } = useCustomFieldInputs({
definitions: cfDefinitions,
values: [] as any[],
entityType: 'product' as CustomFieldEntityType,
entityId: createdEntityId,
context: 'standalone',
})
const hasRequiredCustomFields = computed(() => customFieldInputs.value.some(f => f.required))
const productTypeList = computed<ProductCatalogType[]>(() =>
(productTypes.value || []) as ProductCatalogType[],
@@ -238,6 +263,12 @@ const selectedType = computed(() => {
return productTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
])
watch(
() => route.query.typeId,
(value) => {
@@ -264,27 +295,16 @@ watch(selectedTypeId, (id) => {
watch(selectedType, (type) => {
if (!type) {
clearForm()
customFieldInputs.value = []
cfDefinitions.value = []
return
}
if (!creationForm.name) {
creationForm.name = type.name
}
customFieldInputs.value = normalizeCustomFieldInputsFromUtils(normalizeProductStructureForSave(type.structure))
const normalized = normalizeProductStructureForSave(type.structure)
cfDefinitions.value = normalized?.customFields ?? []
})
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim().length > 0
}),
)
const canSubmit = computed(() => Boolean(
canEdit.value &&
selectedType.value &&
@@ -336,7 +356,8 @@ const submitCreation = async () => {
const result = await createProduct(payload)
if (result.success && result.data?.id) {
const productId = result.data.id
const failedFields = await saveCustomFieldValues(result.data.id)
createdEntityId.value = productId
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
await router.replace(`/product/${result.data.id}?edit=true`)
@@ -375,39 +396,6 @@ const submitCreation = async () => {
}
}
const saveCustomFieldValues = async (productId: string) => {
const failed: string[] = []
for (const field of customFieldInputs.value) {
if (!field.name) {
continue
}
const value = field.value ?? ''
const metadata = field.customFieldId
? undefined
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
const result = await upsertCustomFieldValue(
field.customFieldId,
'product',
productId,
String(value ?? ''),
metadata,
)
if (!result.success) {
failed.push(field.name)
} else {
const createdValue = result.data
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
}
const resolvedId = createdValue?.customField?.id || field.customFieldId
if (resolvedId) {
field.customFieldId = resolvedId
}
}
}
return failed
}
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.constructeurIds,

View File

@@ -4,6 +4,7 @@ import {
type ComponentModelStructure,
type ComponentModelStructureNode,
} from '../types/inventory'
import { mergeDefinitionsWithValues } from '../utils/customFields'
// Import for internal use in this file
import { sanitizeCustomFields, sanitizePieces, sanitizeProducts, sanitizeSubcomponents } from './componentStructureSanitize'
@@ -86,30 +87,22 @@ export const cloneStructure = (input: any): ComponentModelStructure => {
export const normalizeStructureForEditor = (input: any): ComponentModelStructure => {
const source = cloneStructure(input)
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
const customFields = sanitizedCustomFields.map((field) => {
const options = Array.isArray(field.options) ? [...field.options] : []
const optionsText = options.length ? options.join('\n') : ''
const defaultValue =
field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== ''
? String(field.defaultValue)
: null
const copy: ComponentModelCustomField = {
name: field.name,
type: field.type,
required: field.required,
machineContextOnly: !!field.machineContextOnly,
options,
defaultValue,
optionsText,
id: field.id,
customFieldId: field.customFieldId,
}
return copy
})
const merged = mergeDefinitionsWithValues(source.customFields, [])
const customFields: ComponentModelCustomField[] = merged.map((field) => ({
name: field.name,
type: field.type as ComponentModelCustomField['type'],
required: field.required,
machineContextOnly: field.machineContextOnly,
options: field.options,
defaultValue: field.defaultValue,
optionsText: field.optionsText,
id: field.customFieldId ?? undefined,
customFieldId: field.customFieldId ?? undefined,
orderIndex: field.orderIndex,
}))
const result: ComponentModelStructure = {
customFields: customFields as ComponentModelCustomField[],
customFields,
pieces: sanitizePieces(source.pieces),
products: sanitizeProducts(source.products),
subcomponents: hydrateSubcomponents(source.subcomponents),

View File

@@ -15,6 +15,8 @@ export interface DataTableColumn {
headerClass?: string
/** Width hint (e.g. 'w-24', 'min-w-[10rem]') */
width?: string
/** Inline min-width style (e.g. '120px', '8rem'). Only effective with fixedLayout. */
minWidth?: string
/** Text alignment: 'left' (default), 'center', 'right' */
align?: 'left' | 'center' | 'right'
/** Hide on mobile (adds 'hidden sm:table-cell') */

View File

@@ -5,6 +5,13 @@
* copy of extractCollection (parsing hydra:member / member / data / array).
*/
export function 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 extractCollection<T = any>(payload: unknown): T[] {
if (Array.isArray(payload)) return payload as T[]
const p = payload as Record<string, unknown> | null

View File

@@ -1,404 +0,0 @@
/**
* Custom field form normalization, merge, and persistence utilities.
*
* Extracted from pages/component/create.vue, component/[id]/edit.vue,
* pieces/create.vue, pieces/[id]/edit.vue, product/[id]/edit.vue.
*
* Every create/edit page was shipping its own copy of these helpers
* this module unifies them behind a single, entity-agnostic API.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
export interface SaveCustomFieldDeps {
customFieldInputs: { value: CustomFieldInput[] }
upsertCustomFieldValue: (
definitionId: string | null,
entityType: string,
entityId: string,
value: string,
metadata?: Record<string, unknown>,
) => Promise<{ success: boolean; data?: any }>
updateCustomFieldValue: (
id: string,
payload: { value: string },
) => Promise<{ success: boolean }>
toast: { showError: (msg: string) => void }
}
// ---------------------------------------------------------------------------
// Primitive helpers
// ---------------------------------------------------------------------------
export const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return ''
}
export const fieldKey = (field: CustomFieldInput, index: number): string =>
field.customFieldValueId || field.id || `${field.name}-${index}`
// ---------------------------------------------------------------------------
// Field resolution helpers
// ---------------------------------------------------------------------------
export const resolveFieldName = (field: any): string => {
if (typeof field?.name === 'string' && field.name.trim()) return field.name.trim()
if (typeof field?.key === 'string' && field.key.trim()) return field.key.trim()
if (typeof field?.label === 'string' && field.label.trim()) return field.label.trim()
return ''
}
export const resolveFieldType = (field: any): string => {
const allowed = ['text', 'number', 'select', 'boolean', 'date']
const rawType =
typeof field?.type === 'string'
? field.type
: typeof field?.value?.type === 'string'
? field.value.type
: ''
const value = rawType.toLowerCase()
return allowed.includes(value) ? value : 'text'
}
export const resolveRequiredFlag = (field: any): boolean => {
if (typeof field?.required === 'boolean') return field.required
const nested = field?.value?.required
if (typeof nested === 'boolean') return nested
if (typeof nested === 'string') {
const normalized = nested.toLowerCase()
return normalized === 'true' || normalized === '1'
}
return false
}
export const resolveOptions = (field: any): string[] => {
const sources = [field?.options, field?.value?.options, field?.value?.choices]
for (const source of sources) {
if (Array.isArray(source)) {
const mapped = source
.map((option: unknown) => {
if (option === null || option === undefined) return ''
if (typeof option === 'string') return option.trim()
if (typeof option === 'object') {
const record = (option || {}) as Record<string, unknown>
for (const key of ['value', 'label', 'name']) {
const candidate = record[key]
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
}
}
const fallback = String(option).trim()
return fallback === '[object Object]' ? '' : fallback
})
.filter((o) => o.length > 0)
if (mapped.length) return mapped
}
}
return []
}
export const resolveDefaultValue = (field: any): any => {
if (!field || typeof field !== 'object') return null
if (field.defaultValue !== undefined && field.defaultValue !== null) return field.defaultValue
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') return field.value
if (field.default !== undefined && field.default !== null) return field.default
if (field.value && typeof field.value === 'object') {
if (field.value.defaultValue !== undefined && field.value.defaultValue !== null) return field.value.defaultValue
if (field.value.value !== undefined && field.value.value !== null && typeof field.value.value !== 'object') return field.value.value
}
return null
}
export const formatDefaultValue = (type: string, defaultValue: any): string => {
if (defaultValue === null || defaultValue === undefined) return ''
if (typeof defaultValue === 'object') {
if (defaultValue === null) return ''
if ('defaultValue' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
}
if ('value' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
}
return ''
}
if (type === 'boolean') {
const normalized = String(defaultValue).toLowerCase()
if (normalized === 'true' || normalized === '1') return 'true'
if (normalized === 'false' || normalized === '0') return 'false'
return ''
}
return String(defaultValue)
}
// ---------------------------------------------------------------------------
// Normalize a single raw custom-field definition into CustomFieldInput
// ---------------------------------------------------------------------------
export const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') return null
const name = resolveFieldName(rawField)
if (!name) return null
const type = resolveFieldType(rawField)
const required = resolveRequiredFlag(rawField)
const options = resolveOptions(rawField)
const defaultSource = resolveDefaultValue(rawField)
const value = formatDefaultValue(type, defaultSource)
const id = typeof rawField.id === 'string' ? rawField.id : null
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
const customFieldValueId = typeof rawField.customFieldValueId === 'string' ? rawField.customFieldValueId : null
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
}
// ---------------------------------------------------------------------------
// Normalize ALL custom-field definitions from a structure
// ---------------------------------------------------------------------------
export const normalizeCustomFieldInputs = (structure: any): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') return []
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field: any, index: number) => normalizeCustomField(field, index))
.filter((field: CustomFieldInput | null): field is CustomFieldInput => field !== null)
.sort((a: CustomFieldInput, b: CustomFieldInput) => a.orderIndex - b.orderIndex)
}
// ---------------------------------------------------------------------------
// Extract stored value from a persisted custom-field entry
// ---------------------------------------------------------------------------
export const extractStoredCustomFieldValue = (entry: any): any => {
if (entry === null || entry === undefined) return ''
if (typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean') return entry
if (typeof entry !== 'object') return String(entry)
const direct = entry.value
if (direct !== undefined && direct !== null) {
if (typeof direct === 'object') {
if (direct === null) return ''
if ('value' in direct && direct.value !== undefined && direct.value !== null) return direct.value
if ('defaultValue' in direct && direct.defaultValue !== undefined && direct.defaultValue !== null) return direct.defaultValue
return ''
}
return direct
}
if (entry.defaultValue !== undefined && entry.defaultValue !== null) return entry.defaultValue
if (entry.customFieldValue?.value !== undefined && entry.customFieldValue?.value !== null) return entry.customFieldValue.value
return ''
}
// ---------------------------------------------------------------------------
// Build inputs for edit pages (merge definitions + stored values)
// ---------------------------------------------------------------------------
export const buildCustomFieldInputs = (
structure: any,
values: any[] | null | undefined,
): CustomFieldInput[] => {
const definitions = normalizeCustomFieldInputs(structure)
const valueList = Array.isArray(values) ? values : []
const mapById = new Map<string, any>()
const mapByName = new Map<string, any>()
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') return
const fieldId = entry.customField?.id || entry.customFieldId || null
if (fieldId) mapById.set(fieldId, entry)
const fieldName = entry.customField?.name || entry.name || entry.key || null
if (fieldName) mapByName.set(fieldName, entry)
})
const matchedIds = new Set<string>()
const matchedNames = new Set<string>()
const result = definitions
.map((definition) => {
const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
if (!matched) {
return {
...definition,
customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null,
orderIndex: definition.orderIndex,
}
}
const matchedFieldId = matched.customField?.id || matched.customFieldId || null
if (matchedFieldId) matchedIds.add(matchedFieldId)
const matchedFieldName = matched.customField?.name || matched.name || null
if (matchedFieldName) matchedNames.add(matchedFieldName)
const resolvedValue = extractStoredCustomFieldValue(matched)
return {
...definition,
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null,
value: formatDefaultValue(definition.type, resolvedValue),
orderIndex: Math.min(
definition.orderIndex ?? 0,
typeof matched.customField?.orderIndex === 'number'
? matched.customField.orderIndex
: definition.orderIndex ?? 0,
),
}
})
// Include values with embedded definitions that didn't match any structure definition
valueList.forEach((entry, index) => {
if (!entry || typeof entry !== 'object') return
const cf = entry.customField
if (!cf || typeof cf !== 'object') return
const fieldId = cf.id || entry.customFieldId || null
const fieldName = cf.name || entry.name || null
if (fieldId && matchedIds.has(fieldId)) return
if (fieldName && matchedNames.has(fieldName)) return
const name = resolveFieldName(cf)
if (!name) return
const type = resolveFieldType(cf)
const resolvedValue = extractStoredCustomFieldValue(entry)
result.push({
id: fieldId,
name,
type,
required: resolveRequiredFlag(cf),
options: resolveOptions(cf),
value: formatDefaultValue(type, resolvedValue),
customFieldId: fieldId,
customFieldValueId: entry.id ?? null,
orderIndex: typeof cf.orderIndex === 'number' ? cf.orderIndex : definitions.length + index,
})
})
return result.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
}
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
export const buildCustomFieldMetadata = (field: CustomFieldInput): Record<string, unknown> => ({
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
})
export const shouldPersistField = (field: CustomFieldInput): boolean => {
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
return toFieldString(field.value).trim() !== ''
}
export const formatValueForPersistence = (field: CustomFieldInput): string => {
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
return toFieldString(field.value).trim()
}
export const requiredCustomFieldsFilled = (inputs: CustomFieldInput[]): boolean =>
inputs.every((field) => {
if (!field.required) return true
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
return toFieldString(field.value).trim() !== ''
})
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
/**
* Save custom-field values for an entity.
*
* @param entityType - API entity slug ('composant' | 'piece' | 'product')
* @param entityId - ID of the created/updated entity
* @param definitionSources - arrays of raw definition objects to build a name→id map
* @param deps - injected composable references
* @returns list of field names that failed to save (empty = all OK)
*/
export const saveCustomFieldValues = async (
entityType: string,
entityId: string,
definitionSources: any[][],
deps: SaveCustomFieldDeps,
): Promise<string[]> => {
if (!entityId) return []
const definitionMap = new Map<string, string>()
const registerDefinitions = (fields: any[]) => {
if (!Array.isArray(fields)) return
fields.forEach((field) => {
if (!field || typeof field !== 'object') return
const name = typeof field.name === 'string' ? field.name : null
const id = typeof field.id === 'string' ? field.id : null
if (name && id && !definitionMap.has(name)) definitionMap.set(name, id)
})
}
definitionSources.forEach(registerDefinitions)
const resolveDefinitionId = (field: CustomFieldInput) => {
if (field.customFieldId) return field.customFieldId
if (field.id) return field.id
return definitionMap.get(field.name) ?? null
}
const failed: string[] = []
for (const field of deps.customFieldInputs.value) {
if (!shouldPersistField(field)) continue
const definitionId = resolveDefinitionId(field)
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
const value = formatValueForPersistence(field)
if (field.customFieldValueId) {
const result = await deps.updateCustomFieldValue(field.customFieldValueId, { value })
if (!result.success) {
deps.toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
failed.push(field.name)
} else if (definitionId && !field.customFieldId) {
field.customFieldId = definitionId
}
continue
}
const result = await deps.upsertCustomFieldValue(
definitionId,
entityType,
entityId,
value,
metadata,
)
if (!result.success) {
deps.toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
failed.push(field.name)
} else {
const createdValue = result.data
if (createdValue?.id) field.customFieldValueId = createdValue.id
const resolvedId = createdValue?.customField?.id || definitionId
if (resolvedId) field.customFieldId = resolvedId
}
}
return failed
}

View File

@@ -1,440 +0,0 @@
/**
* Custom field normalization, merging and display utilities.
*
* Extracted from pages/machine/[id].vue to be reusable across
* machine detail, component, piece and product views.
*/
// ---------------------------------------------------------------------------
// Primitive helpers
// ---------------------------------------------------------------------------
export const coerceValueForType = (type: string, rawValue: unknown): string => {
if (rawValue === undefined || rawValue === null || rawValue === '') {
return ''
}
if (type === 'boolean') {
const normalized = String(rawValue).toLowerCase()
if (normalized === 'true' || normalized === '1') return 'true'
if (normalized === 'false' || normalized === '0') return 'false'
return ''
}
return String(rawValue)
}
export const formatCustomFieldValue = (field: Record<string, unknown> | null | undefined): string => {
if (!field) return 'Non défini'
const value = (field.value ?? field.defaultValue ?? '') as string
if (value === '' || value === null || value === undefined) return 'Non défini'
if (field.type === 'boolean') {
const normalized = String(value).toLowerCase()
if (normalized === 'true' || normalized === '1') return 'Oui'
if (normalized === 'false' || normalized === '0') return 'Non'
}
return String(value)
}
export const shouldDisplayCustomField = (field: Record<string, unknown> | null | undefined): boolean => {
if (!field) return false
if (field.readOnly) return true
if (field.type === 'boolean') return field.value !== undefined && field.value !== null
const value = field.value
if (value === null || value === undefined) return false
if (typeof value === 'string') return value.trim().length > 0
return true
}
// ---------------------------------------------------------------------------
// Definition extraction helpers
// ---------------------------------------------------------------------------
export const extractDefinitionName = (definition: Record<string, unknown> = {}): string => {
if (typeof definition?.name === 'string' && (definition.name as string).trim()) {
return (definition.name as string).trim()
}
if (typeof definition?.key === 'string' && (definition.key as string).trim()) {
return (definition.key as string).trim()
}
if (typeof definition?.label === 'string' && (definition.label as string).trim()) {
return (definition.label as string).trim()
}
return ''
}
export const extractDefinitionType = (
definition: Record<string, unknown> = {},
fallback = 'text',
): string => {
const allowed = ['text', 'number', 'select', 'boolean', 'date']
const rawType =
typeof definition?.type === 'string'
? definition.type
: typeof (definition?.value as Record<string, unknown>)?.type === 'string'
? (definition.value as Record<string, unknown>).type as string
: typeof fallback === 'string'
? fallback
: 'text'
const normalized = (rawType as string).toLowerCase()
return allowed.includes(normalized) ? normalized : 'text'
}
export const extractDefinitionRequired = (
definition: Record<string, unknown> = {},
fallback = false,
): boolean => {
if (typeof definition?.required === 'boolean') return definition.required
const nested = (definition?.value as Record<string, unknown>)?.required
if (typeof nested === 'boolean') return nested
if (typeof nested === 'string') {
const normalized = nested.toLowerCase()
if (normalized === 'true' || normalized === '1') return true
if (normalized === 'false' || normalized === '0') return false
}
return !!fallback
}
const extractOptionList = (input: unknown): string[] | undefined => {
if (!Array.isArray(input)) return undefined
const mapped = input
.map((option) => {
if (option === null || option === undefined) return ''
if (typeof option === 'string') return option.trim()
if (typeof option === 'object') {
const record = (option || {}) as Record<string, unknown>
for (const key of ['value', 'label', 'name']) {
const candidate = record[key]
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
}
}
const fallback = String(option).trim()
return fallback === '[object Object]' ? '' : fallback
})
.filter((option) => option.length > 0)
return mapped.length ? mapped : undefined
}
export const extractDefinitionOptions = (definition: Record<string, unknown> = {}): string[] => {
const sources = [
definition?.options,
(definition?.value as Record<string, unknown>)?.options,
(definition?.value as Record<string, unknown>)?.choices,
]
for (const source of sources) {
const list = extractOptionList(source)
if (list) return list
}
return []
}
export const extractDefinitionDefaultValue = (definition: Record<string, unknown> = {}): unknown => {
const candidates = [
definition?.defaultValue,
(definition?.value as Record<string, unknown>)?.defaultValue,
(definition?.value as Record<string, unknown>)?.value,
definition?.value,
definition?.default,
]
for (const candidate of candidates) {
if (candidate === undefined || candidate === null || candidate === '') continue
if (typeof candidate === 'object') {
if (candidate === null) continue
const nestedDefault =
(candidate as Record<string, unknown>).defaultValue !== undefined &&
(candidate as Record<string, unknown>).defaultValue !== null
? (candidate as Record<string, unknown>).defaultValue
: (candidate as Record<string, unknown>).value
if (nestedDefault !== undefined && nestedDefault !== null && nestedDefault !== '') {
return nestedDefault
}
continue
}
return candidate
}
return undefined
}
// ---------------------------------------------------------------------------
// Normalization
// ---------------------------------------------------------------------------
export interface NormalizedCustomFieldDefinition {
id?: string
customFieldId?: string
name: string
type: string
required: boolean
options: string[]
defaultValue?: unknown
readOnly: boolean
orderIndex: number
}
export interface NormalizedCustomFieldEntry {
customFieldValueId: unknown
id: string | undefined
customFieldId: string | null
name: string
type: string
required: boolean
options: string[]
optionsText: string
defaultValue: unknown
value: string
readOnly: boolean
}
export const normalizeCustomFieldDefinitionEntry = (
definition: Record<string, unknown> = {},
fallbackIndex = 0,
): NormalizedCustomFieldDefinition | null => {
const name = extractDefinitionName(definition)
if (!name) return null
const type = extractDefinitionType(definition)
const required = extractDefinitionRequired(definition)
const options = extractDefinitionOptions(definition)
const defaultValue = extractDefinitionDefaultValue(definition)
const id = typeof definition?.id === 'string' ? definition.id : undefined
const customFieldId = typeof definition?.customFieldId === 'string' ? definition.customFieldId : id
const orderIndex = typeof definition?.orderIndex === 'number' ? definition.orderIndex : fallbackIndex
return { id, customFieldId, name, type, required, options, defaultValue, readOnly: !!definition?.readOnly, orderIndex }
}
export const normalizeExistingCustomFieldDefinitions = (
fields: unknown,
): NormalizedCustomFieldDefinition[] => {
if (!Array.isArray(fields)) return []
return fields
.map((field, index) => normalizeCustomFieldDefinitionEntry(field as Record<string, unknown>, index))
.filter((d): d is NormalizedCustomFieldDefinition => d !== null)
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
}
// ---------------------------------------------------------------------------
// Custom field value normalization
// ---------------------------------------------------------------------------
export const normalizeCustomFieldValueEntry = (entry: Record<string, unknown> = {}): Record<string, unknown> | null => {
if (!entry || typeof entry !== 'object') return null
const normalizedDefinition = normalizeCustomFieldDefinitionEntry(entry)
if (!normalizedDefinition) return null
const value = coerceValueForType(
normalizedDefinition.type,
(entry?.value ?? entry?.defaultValue ?? normalizedDefinition.defaultValue ?? '') as string,
)
return {
id: (entry?.customFieldValueId ?? entry?.id ?? null) as string | null,
customFieldId:
(entry?.customFieldId ?? normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null) as string | null,
customField: {
id: normalizedDefinition.id ?? normalizedDefinition.customFieldId ?? null,
name: normalizedDefinition.name,
type: normalizedDefinition.type,
required: normalizedDefinition.required,
options: normalizedDefinition.options,
defaultValue: normalizedDefinition.defaultValue ?? '',
},
value,
}
}
// ---------------------------------------------------------------------------
// Merge & dedup
// ---------------------------------------------------------------------------
export const mergeCustomFieldValuesWithDefinitions = (
valueEntries: Record<string, unknown>[] = [],
...definitionSources: unknown[][]
): Record<string, unknown>[] => {
const normalizedValues: Record<string, unknown>[] = (Array.isArray(valueEntries) ? valueEntries : [])
.map((entry): Record<string, unknown> | null => {
if (!entry || typeof entry !== 'object') return null
const normalizedDefinition = normalizeCustomFieldDefinitionEntry(
((entry as Record<string, unknown>).customField || entry) as Record<string, unknown>,
)
if (!normalizedDefinition) return null
const value = coerceValueForType(
normalizedDefinition.type,
((entry as Record<string, unknown>)?.value ??
(entry as Record<string, unknown>)?.defaultValue ??
normalizedDefinition.defaultValue ??
'') as string,
)
return {
customFieldValueId: (entry as Record<string, unknown>)?.id ?? (entry as Record<string, unknown>)?.customFieldValueId ?? null,
id: normalizedDefinition.id,
customFieldId: normalizedDefinition.customFieldId ?? normalizedDefinition.id ?? null,
name: normalizedDefinition.name,
type: normalizedDefinition.type,
required: normalizedDefinition.required,
options: normalizedDefinition.options,
optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '',
defaultValue: normalizedDefinition.defaultValue ?? '',
value,
readOnly: !!(entry as Record<string, unknown>)?.readOnly,
} as Record<string, unknown>
})
.filter((entry): entry is Record<string, unknown> => entry !== null)
const result = [...normalizedValues]
const keyFor = (item: Record<string, unknown>) => (item?.id as string) ?? `${item?.name ?? ''}::${item?.type ?? ''}`
const existingMap = new Map<string, Record<string, unknown>>()
result.forEach((item) => {
const key = keyFor(item)
if (key) existingMap.set(key, item)
const fallbackKey = item?.name ? `${item.name}::${item.type ?? ''}` : null
if (fallbackKey) existingMap.set(fallbackKey, item)
})
const definitions = definitionSources
.flatMap((source) => (Array.isArray(source) ? source : []))
.map((definition) => normalizeCustomFieldDefinitionEntry(definition as Record<string, unknown>))
.filter((d): d is NormalizedCustomFieldDefinition => d !== null)
definitions.forEach((normalizedDefinition) => {
const key = normalizedDefinition.id ?? `${normalizedDefinition.name}::${normalizedDefinition.type}`
if (!key) return
if (normalizedDefinition.id) {
const fallbackKey = `${normalizedDefinition.name}::${normalizedDefinition.type}`
if (existingMap.has(fallbackKey)) {
const existingFallback = existingMap.get(fallbackKey)
if (existingFallback) {
existingFallback.id = existingFallback.id || normalizedDefinition.id
existingFallback.customFieldId = normalizedDefinition.id
existingFallback.readOnly = (existingFallback.readOnly as boolean) && normalizedDefinition.readOnly
existingMap.delete(fallbackKey)
existingMap.set(normalizedDefinition.id, existingFallback)
existingMap.set(fallbackKey, existingFallback)
return
}
}
}
const existing =
existingMap.get(key) ||
(normalizedDefinition.name ? existingMap.get(`${normalizedDefinition.name}::${normalizedDefinition.type}`) : null)
if (existing) {
existing.name = existing.name || normalizedDefinition.name
existing.type = existing.type || normalizedDefinition.type
existing.required = (existing.required as boolean) || normalizedDefinition.required
if (!(existing.options as string[])?.length && normalizedDefinition.options?.length) {
existing.options = normalizedDefinition.options
}
if (!existing.defaultValue && normalizedDefinition.defaultValue) {
existing.defaultValue = String(normalizedDefinition.defaultValue)
if (!existing.value) {
existing.value = coerceValueForType(existing.type as string, normalizedDefinition.defaultValue)
}
}
existing.customFieldId = existing.customFieldId || normalizedDefinition.id
existing.readOnly = (existing.readOnly as boolean) && normalizedDefinition.readOnly
if (!existing.optionsText && normalizedDefinition.options?.length) {
existing.optionsText = normalizedDefinition.options.join('\n')
}
if (normalizedDefinition.id) existingMap.set(normalizedDefinition.id, existing)
if (normalizedDefinition.name) {
existingMap.set(`${normalizedDefinition.name}::${normalizedDefinition.type}`, existing)
}
return
}
const entry: Record<string, unknown> = {
customFieldValueId: null,
id: normalizedDefinition.id,
customFieldId: normalizedDefinition.id,
name: normalizedDefinition.name,
type: normalizedDefinition.type,
required: normalizedDefinition.required,
options: normalizedDefinition.options,
optionsText: normalizedDefinition.options?.length ? normalizedDefinition.options.join('\n') : '',
defaultValue: normalizedDefinition.defaultValue ?? '',
value: coerceValueForType(normalizedDefinition.type, (normalizedDefinition.defaultValue ?? '') as string),
readOnly: false,
}
result.push(entry)
existingMap.set(key, entry)
const fallbackKey = entry.name ? `${entry.name}::${entry.type}` : null
if (fallbackKey) existingMap.set(fallbackKey, entry)
})
return result
}
export const dedupeCustomFieldEntries = (fields: Record<string, unknown>[]): Record<string, unknown>[] => {
if (!Array.isArray(fields) || fields.length <= 1) {
return Array.isArray(fields) ? fields : []
}
const seen = new Set<string>()
const result: Record<string, unknown>[] = []
for (const field of fields) {
if (!field) continue
field.type = field.type || 'text'
let normalizedName = typeof field.name === 'string' ? (field.name as string).trim() : ''
if (!normalizedName && (field.customField as Record<string, unknown>)?.name) {
normalizedName = String((field.customField as Record<string, unknown>).name).trim()
field.name = normalizedName
} else if (typeof field.name === 'string') {
field.name = normalizedName
}
const key =
(field.customFieldId as string) ||
(field.id as string) ||
(normalizedName ? `${normalizedName}::${field.type || 'text'}` : null)
if (!key && !normalizedName) continue
if (key && seen.has(key)) continue
if (!normalizedName) continue
if (key) seen.add(key)
if (normalizedName) seen.add(`${normalizedName}::${field.type || 'text'}`)
result.push(field)
}
return result
}
// ---------------------------------------------------------------------------
// Summarize for display
// ---------------------------------------------------------------------------
export const summarizeCustomFields = (
fields: Record<string, unknown>[] = [],
): { key: string; label: string; value: string }[] => {
const seen = new Set<string>()
return fields
.slice()
.sort((a, b) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return (left as number) - (right as number)
})
.filter(shouldDisplayCustomField)
.filter((field) => {
const key = (field.customFieldId || field.id || field.name) as string
if (!key) return true
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((field, index) => ({
key: ((field.customFieldId || field.id || field.name) as string) || `custom-field-${index}`,
label: (field.name as string) || 'Champ',
value: formatCustomFieldValue(field),
}))
}

View File

@@ -0,0 +1,305 @@
/**
* 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'
if (typeof field.value === 'number') return !Number.isNaN(field.value)
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'
if (typeof field.value === 'number') return String(field.value)
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)
})
}

View File

@@ -1,335 +0,0 @@
/**
* Pure functions for custom field resolution, merging, and deduplication.
*
* Extracted from ComponentItem.vue and PieceItem.vue which had ~350 LOC
* of identical custom field logic duplicated between them.
*/
// ---------------------------------------------------------------------------
// Field key / identity helpers
// ---------------------------------------------------------------------------
export function fieldKeyFromNameAndType(name: unknown, type: unknown): string | null {
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''
const normalizedType = typeof type === 'string' ? type.trim().toLowerCase() : ''
return normalizedName ? `${normalizedName}::${normalizedType}` : null
}
export function resolveOrderIndex(field: any): number {
if (!field || typeof field !== 'object') return 0
if (typeof field.orderIndex === 'number') return field.orderIndex
if (field.customField && typeof field.customField.orderIndex === 'number') return field.customField.orderIndex
return 0
}
// ---------------------------------------------------------------------------
// Field accessors
// ---------------------------------------------------------------------------
export function resolveFieldKey(field: any, index: number): string {
return field?.id ?? field?.customFieldValueId ?? field?.customFieldId ?? field?.name ?? `field-${index}`
}
export function resolveFieldId(field: any): string | null {
return field?.customFieldValueId ?? null
}
export function resolveFieldName(field: any): string {
return field?.name ?? 'Champ'
}
export function resolveFieldType(field: any): string {
return field?.type ?? 'text'
}
export function resolveFieldOptions(field: any): string[] {
return field?.options ?? []
}
export function resolveFieldRequired(field: any): boolean {
return !!field?.required
}
export function resolveFieldReadOnly(field: any): boolean {
return !!field?.readOnly
}
export function resolveCustomFieldId(field: any): string | null {
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
}
export function buildCustomFieldMetadata(field: any) {
return {
customFieldName: resolveFieldName(field),
customFieldType: resolveFieldType(field),
customFieldRequired: resolveFieldRequired(field),
customFieldOptions: resolveFieldOptions(field),
}
}
export function formatFieldDisplayValue(field: any): string {
const type = resolveFieldType(field)
const rawValue = field?.value ?? ''
if (type === 'boolean') {
const normalized = String(rawValue).toLowerCase()
if (normalized === 'true') return 'Oui'
if (normalized === 'false') return 'Non'
}
return rawValue || 'Non défini'
}
// ---------------------------------------------------------------------------
// Custom field ID resolution against candidate pool
// ---------------------------------------------------------------------------
export function ensureCustomFieldId(field: any, candidateFields: any[]): string | null {
const existingId = resolveCustomFieldId(field)
if (existingId) return existingId
const name = resolveFieldName(field)
if (!name || name === 'Champ') return null
const matches = candidateFields.filter((candidate) => {
if (!candidate || typeof candidate !== 'object') return false
const candidateId = candidate.id || candidate.customFieldId
if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) return true
return typeof candidate.name === 'string' && candidate.name === name
})
if (matches.length) {
const withId = matches.find((c) => c?.id || c?.customFieldId) || matches[0]
const id = withId?.id || withId?.customFieldId || null
if (id) field.customFieldId = id
if (!field.customField && typeof withId === 'object') field.customField = withId
return id
}
return null
}
// ---------------------------------------------------------------------------
// Structure extraction
// ---------------------------------------------------------------------------
export function extractStructureCustomFields(structure: any): any[] {
if (!structure || typeof structure !== 'object') return []
const customFields = structure.customFields
return Array.isArray(customFields) ? customFields : []
}
// ---------------------------------------------------------------------------
// Deduplication & merge
// ---------------------------------------------------------------------------
export function deduplicateFieldDefinitions(definitions: any[]): any[] {
const result: any[] = []
const seenIds = new Set<string>()
const seenNames = new Set<string>()
const orderedDefinitions = (Array.isArray(definitions) ? definitions.slice() : [])
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
orderedDefinitions.forEach((field) => {
if (!field || typeof field !== 'object') return
const id = field.id ?? field.customFieldId ?? field.customField?.id ?? null
const nameKey = fieldKeyFromNameAndType(field.name, field.type)
// Deduplicate by name+type (primary) AND by id — a field with the same
// name+type is the same field even when stored with different IDs.
if (nameKey && seenNames.has(nameKey)) return
if (id && seenIds.has(id)) return
if (id) seenIds.add(id)
if (nameKey) seenNames.add(nameKey)
field.orderIndex = resolveOrderIndex(field)
result.push(field)
})
return result
}
export function mergeFieldDefinitionsWithValues(definitions: any[], values: any[]): any[] {
const definitionList = Array.isArray(definitions) ? definitions : []
const valueList = Array.isArray(values) ? values : []
const valueMap = new Map<string, any>()
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') return
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
if (fieldId) valueMap.set(fieldId, entry)
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
if (nameKey) valueMap.set(nameKey, entry)
})
const merged = definitionList.map((field) => {
if (!field || typeof field !== 'object') return field
const fieldId = resolveCustomFieldId(field)
const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
const matchedValue = (fieldId ? valueMap.get(fieldId) : undefined) ?? (nameKey ? valueMap.get(nameKey) : undefined)
if (!matchedValue) {
return { ...field, value: field?.value ?? '', orderIndex: resolveOrderIndex(field) }
}
const resolvedOrder = Math.min(resolveOrderIndex(field), resolveOrderIndex(matchedValue.customField))
return {
...field,
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
customFieldId: matchedValue.customField?.id ?? matchedValue.customFieldId ?? fieldId ?? null,
customField: matchedValue.customField ?? field.customField ?? null,
value: matchedValue.value ?? field.value ?? '',
orderIndex: resolvedOrder,
}
})
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') return
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
const exists = merged.some((field) => {
if (!field || typeof field !== 'object') return false
if (field.customFieldValueId && field.customFieldValueId === entry.id) return true
const existingId = resolveCustomFieldId(field)
if (fieldId && existingId && existingId === fieldId) return true
if (!fieldId && nameKey) {
return fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
}
return false
})
if (!exists) {
merged.push({
customFieldValueId: entry.id ?? null,
customFieldId: fieldId,
name: entry.customField?.name ?? '',
type: entry.customField?.type ?? 'text',
required: entry.customField?.required ?? false,
options: entry.customField?.options ?? [],
value: entry.value ?? '',
customField: entry.customField ?? null,
orderIndex: resolveOrderIndex(entry.customField),
})
}
})
return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
}
export function dedupeMergedFields(fields: any[]): any[] {
if (!Array.isArray(fields) || fields.length <= 1) {
return Array.isArray(fields)
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
: []
}
const seenById = new Map<string, any>()
const seenByName = new Map<string, any>()
const result: any[] = []
const orderedFields = fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
orderedFields.forEach((field) => {
if (!field || typeof field !== 'object') return
const rawName = resolveFieldName(field)
const normalizedName = typeof rawName === 'string' ? rawName.trim() : ''
if (!normalizedName) return
field.name = normalizedName
field.type = field.type || resolveFieldType(field)
const fieldId = resolveCustomFieldId(field)
const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
// Check duplicates by name+type first (same field can have different IDs)
const existing = (nameKey ? seenByName.get(nameKey) : undefined) ?? (fieldId ? seenById.get(fieldId) : undefined)
if (!existing) {
field.orderIndex = resolveOrderIndex(field)
if (fieldId) seenById.set(fieldId, field)
if (nameKey) seenByName.set(nameKey, field)
result.push(field)
return
}
const existingHasValue = existing.value !== undefined && existing.value !== null && String(existing.value).trim().length > 0
const incomingHasValue = field.value !== undefined && field.value !== null && String(field.value).trim().length > 0
if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field)
existing.orderIndex = Math.min(resolveOrderIndex(existing), resolveOrderIndex(field))
if (fieldId) seenById.set(fieldId, existing)
if (nameKey) seenByName.set(nameKey, existing)
}
})
return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
}
// ---------------------------------------------------------------------------
// Definition sources builder
// ---------------------------------------------------------------------------
export function buildDefinitionSources(entity: any, entityType: 'composant' | 'piece'): any[] {
const definitions: any[] = []
const pushFields = (collection: any) => {
if (Array.isArray(collection)) definitions.push(...collection)
}
if (entityType === 'composant') {
const type = entity.typeComposant || {}
pushFields(entity.customFields)
pushFields(entity.definition?.customFields)
pushFields(type.customFields)
;[
entity.definition?.structure,
type.structure,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure)
if (fields.length) definitions.push(...fields)
})
} else {
const type = entity.typePiece || {}
pushFields(entity.customFields)
pushFields(entity.definition?.customFields)
pushFields(type.customFields)
;[
entity.definition?.structure,
type.structure,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure)
if (fields.length) definitions.push(...fields)
})
}
return deduplicateFieldDefinitions(definitions)
}
// ---------------------------------------------------------------------------
// Candidate fields builder
// ---------------------------------------------------------------------------
export function buildCandidateCustomFields(entity: any, definitionSources: any[]): any[] {
const map = new Map<string, any>()
const register = (collection: any[]) => {
if (!Array.isArray(collection)) return
collection.forEach((item) => {
if (!item || typeof item !== 'object') return
const id = item.id || item.customFieldId
const name = typeof item.name === 'string' ? item.name : null
const key = id || (name ? `${name}::${item.type ?? ''}` : null)
if (!key || map.has(key)) return
map.set(key, item)
})
}
register((entity.customFieldValues || []).map((value: any) => value?.customField))
register(definitionSources)
return Array.from(map.values())
}

View File

@@ -0,0 +1,737 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mockLinkSKF, mockLinkFAG } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
const mockPostFormData = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: mockPostFormData,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — Toast
// ---------------------------------------------------------------------------
const mockShowSuccess = vi.fn()
const mockShowError = vi.fn()
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: mockShowSuccess,
showError: mockShowError,
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useComposants (createComposant)
// ---------------------------------------------------------------------------
const mockCreateComposant = vi.fn()
vi.mock('~/composables/useComposants', () => ({
useComposants: () => ({
createComposant: mockCreateComposant,
composants: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePieces, useProducts
// ---------------------------------------------------------------------------
vi.mock('~/composables/usePieces', () => ({
usePieces: () => ({
pieces: { value: [] },
loading: { value: false },
}),
}))
vi.mock('~/composables/useProducts', () => ({
useProducts: () => ({
products: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useComponentTypes, usePieceTypes, useProductTypes
// ---------------------------------------------------------------------------
const mockLoadComponentTypes = vi.fn().mockResolvedValue(undefined)
const mockComponentTypes = { value: [] as any[] }
vi.mock('~/composables/useComponentTypes', () => ({
useComponentTypes: () => ({
componentTypes: mockComponentTypes,
loadComponentTypes: mockLoadComponentTypes,
loadingComponentTypes: { value: false },
}),
}))
vi.mock('~/composables/usePieceTypes', () => ({
usePieceTypes: () => ({
pieceTypes: { value: [] },
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useProductTypes', () => ({
useProductTypes: () => ({
productTypes: { value: [] },
loadProductTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useDocuments (uploadDocuments)
// ---------------------------------------------------------------------------
const mockUploadDocuments = vi.fn()
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
uploadDocuments: mockUploadDocuments,
documents: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurLinks (syncLinks)
// ---------------------------------------------------------------------------
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
vi.mock('~/composables/useConstructeurLinks', () => ({
useConstructeurLinks: () => ({
fetchLinks: vi.fn().mockResolvedValue([]),
syncLinks: mockSyncLinks,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useCustomFieldInputs (saveAll)
// ---------------------------------------------------------------------------
const mockSaveAll = vi.fn().mockResolvedValue([])
const mockRefreshCF = vi.fn()
vi.mock('~/composables/useCustomFieldInputs', () => ({
useCustomFieldInputs: () => ({
fields: { value: [] },
requiredFilled: { value: true },
saveAll: mockSaveAll,
refresh: mockRefreshCF,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePermissions (auto-imported in Nuxt)
// ---------------------------------------------------------------------------
// usePermissions is Nuxt auto-imported (no explicit import in source),
// so we stub it as a global function.
vi.stubGlobal('usePermissions', () => ({
canEdit: { value: true },
canManage: { value: true },
isAdmin: { value: false },
isGranted: () => true,
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurs (used by useComposants internally)
// ---------------------------------------------------------------------------
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — shared utils that touch structure
// ---------------------------------------------------------------------------
const mockHasAssignments = vi.fn().mockReturnValue(false)
const mockSerializeStructureAssignments = vi.fn().mockReturnValue(null)
const mockIsAssignmentNodeComplete = vi.fn().mockReturnValue(true)
vi.mock('~/shared/utils/structureAssignmentHelpers', () => ({
hasAssignments: (...args: any[]) => mockHasAssignments(...args),
initializeStructureAssignments: () => null,
isAssignmentNodeComplete: (...args: any[]) => mockIsAssignmentNodeComplete(...args),
serializeStructureAssignments: (...args: any[]) => mockSerializeStructureAssignments(...args),
}))
vi.mock('~/shared/utils/structureDisplayUtils', () => ({
getStructurePieces: () => [],
resolvePieceLabel: (p: any) => p?.name ?? '',
resolveProductLabel: (p: any) => p?.name ?? '',
resolveSubcomponentLabel: (p: any) => p?.name ?? '',
fetchModelTypeNames: vi.fn().mockResolvedValue({}),
buildTypeLabelMap: () => ({}),
}))
vi.mock('~/shared/modelUtils', () => ({
formatStructurePreview: () => '',
normalizeStructureForEditor: (s: any) => s,
}))
vi.mock('~/shared/utils/errorMessages', () => ({
humanizeError: (msg: string) => msg,
}))
vi.mock('~/shared/constructeurUtils', () => ({
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentCreate } from '~/composables/useComponentCreate'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** A minimal ModelType matching the `COMPONENT` category filter. */
const mockModelType = {
id: 'tc-moteur',
name: 'Moteur électrique',
category: 'COMPONENT',
structure: null,
}
beforeEach(() => {
vi.clearAllMocks()
// Provide at least one COMPONENT type so selectedType resolves
mockComponentTypes.value = [mockModelType]
})
// ---------------------------------------------------------------------------
// submitCreation — payload completeness
// ---------------------------------------------------------------------------
describe('submitCreation — payload completeness', () => {
it('includes all form fields in createComposant payload', async () => {
const createdComp = { id: 'comp-new-001', name: 'Moteur principal' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
// Select a type
composable.selectedTypeId.value = 'tc-moteur'
// Wait a tick so watchers fire
await new Promise(r => setTimeout(r, 0))
// Fill form fields
composable.creationForm.name = 'Moteur principal'
composable.creationForm.description = 'Un moteur triphasé'
composable.creationForm.reference = 'MOT-001'
composable.creationForm.prix = '1500'
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).toMatchObject({
name: 'Moteur principal',
description: 'Un moteur triphasé',
reference: 'MOT-001',
prix: '1500',
typeComposantId: 'tc-moteur',
})
})
it('saves custom fields after component creation (saveAll is called)', async () => {
const createdComp = { id: 'comp-cf-001', name: 'Composant CF' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant CF'
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
expect(mockSaveAll).toHaveBeenCalledTimes(1)
})
it('syncs constructeur links after creation with correct entity type and ID', async () => {
const createdComp = { id: 'comp-link-001', name: 'Composant Links' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Links'
// Add constructeur links
composable.constructeurLinks.value = [mockLinkSKF, mockLinkFAG]
await composable.submitCreation()
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
expect(mockSyncLinks).toHaveBeenCalledWith(
'composant',
'comp-link-001',
[],
[mockLinkSKF, mockLinkFAG],
)
})
it('uploads documents with correct composantId context', async () => {
const createdComp = { id: 'comp-doc-001', name: 'Composant Docs' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Docs'
// Simulate selected documents
const fakeFile = new File(['content'], 'schema.pdf', { type: 'application/pdf' })
composable.selectedDocuments.value = [fakeFile]
await composable.submitCreation()
expect(mockUploadDocuments).toHaveBeenCalledTimes(1)
expect(mockUploadDocuments).toHaveBeenCalledWith(
{
files: [fakeFile],
context: { composantId: 'comp-doc-001' },
},
{ updateStore: false },
)
})
it('does not crash with zero constructeurs', async () => {
const createdComp = { id: 'comp-no-cstr', name: 'Composant Simple' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Simple'
// Ensure no constructeur links
composable.constructeurLinks.value = []
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
expect(mockSyncLinks).not.toHaveBeenCalled()
expect(mockShowSuccess).toHaveBeenCalledWith('Composant créé avec succès')
})
})
// ---------------------------------------------------------------------------
// Structure serialization in payload
// ---------------------------------------------------------------------------
describe('submitCreation — structure serialization in payload', () => {
it('includes structure key with serialized data when assignments exist', async () => {
const createdComp = { id: 'comp-struct-001', name: 'Composant Structure' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const fakeSerializedStructure = {
path: 'root',
definition: { typeComposantId: 'tc-moteur' },
pieces: [{ path: 'root:piece-0', definition: { typePieceId: 'tp-001' }, selectedPieceId: 'piece-abc' }],
}
mockHasAssignments.mockReturnValue(true)
mockSerializeStructureAssignments.mockReturnValue(fakeSerializedStructure)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Structure'
// Set a non-null structureAssignments so the composable considers it present
composable.structureAssignments.value = {
path: 'root',
definition: {} as any,
selectedComponentId: '',
pieces: [{ path: 'root:piece-0', definition: {} as any, selectedPieceId: 'piece-abc' }],
products: [],
subcomponents: [],
}
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload.structure).toEqual(fakeSerializedStructure)
})
it('does not include structure key when no assignments exist', async () => {
const createdComp = { id: 'comp-nostruct-001', name: 'Composant No Structure' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
// Reset to default: no assignments
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant No Structure'
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload.structure).toBeUndefined()
})
it('does not include structure key when serializeStructureAssignments returns null', async () => {
const createdComp = { id: 'comp-sernull-001', name: 'Composant Serialize Null' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockHasAssignments.mockReturnValue(true)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Serialize Null'
composable.structureAssignments.value = {
path: 'root',
definition: {} as any,
selectedComponentId: '',
pieces: [],
products: [],
subcomponents: [],
}
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload.structure).toBeUndefined()
})
})
// ---------------------------------------------------------------------------
// Prix / reference null handling
// ---------------------------------------------------------------------------
describe('submitCreation — prix and reference null handling', () => {
it('does not send prix when prix is an empty string', async () => {
const createdComp = { id: 'comp-noprix-001', name: 'Composant No Prix' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
// Reset structure mocks to default
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant No Prix'
composable.creationForm.prix = ''
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('prix')
})
it('does not send prix when prix is non-numeric (avoids NaN)', async () => {
const createdComp = { id: 'comp-nanprix-001', name: 'Composant NaN Prix' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant NaN Prix'
composable.creationForm.prix = 'not-a-number'
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('prix')
})
it('sends prix as stringified number when valid numeric string', async () => {
const createdComp = { id: 'comp-validprix-001', name: 'Composant Valid Prix' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Valid Prix'
composable.creationForm.prix = ' 42.5 '
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload.prix).toBe('42.5')
})
it('does not send reference when reference is an empty string', async () => {
const createdComp = { id: 'comp-noref-001', name: 'Composant No Ref' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant No Ref'
composable.creationForm.reference = ''
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('reference')
})
it('does not send reference when reference is whitespace only', async () => {
const createdComp = { id: 'comp-wsref-001', name: 'Composant WS Ref' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant WS Ref'
composable.creationForm.reference = ' '
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('reference')
})
})
// ---------------------------------------------------------------------------
// Error paths
// ---------------------------------------------------------------------------
describe('submitCreation — error paths', () => {
beforeEach(() => {
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
})
it('does not save custom fields when createComposant returns success: false', async () => {
mockCreateComposant.mockResolvedValue({ success: false, error: 'Duplicate name' })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Fail'
await composable.submitCreation()
expect(mockCreateComposant).toHaveBeenCalledTimes(1)
expect(mockSaveAll).not.toHaveBeenCalled()
expect(mockShowError).toHaveBeenCalledWith('Duplicate name')
})
it('shows toast error when createComposant returns success: false with error message', async () => {
mockCreateComposant.mockResolvedValue({ success: false, error: 'Server validation failed' })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Error'
await composable.submitCreation()
expect(mockShowError).toHaveBeenCalledWith('Server validation failed')
expect(mockShowSuccess).not.toHaveBeenCalled()
})
it('shows warning for failed custom fields but still navigates (composant exists)', async () => {
const createdComp = { id: 'comp-cf-warn-001', name: 'Composant CF Warn' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
mockSaveAll.mockResolvedValue(['Tension nominale', 'Certifié CE'])
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant CF Warn'
await composable.submitCreation()
// Custom field error toast is shown
expect(mockShowError).toHaveBeenCalledWith(
'Erreur sur les champs : Tension nominale, Certifié CE',
)
// But creation success toast is also shown (composant was created)
expect(mockShowSuccess).toHaveBeenCalledWith('Composant créé avec succès')
})
it('catches thrown exceptions and shows humanized error', async () => {
mockCreateComposant.mockRejectedValue(new Error('Network timeout'))
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Throw'
await composable.submitCreation()
expect(mockShowError).toHaveBeenCalledWith('Network timeout')
expect(mockSaveAll).not.toHaveBeenCalled()
})
it('resets submitting flag after failure', async () => {
mockCreateComposant.mockResolvedValue({ success: false, error: 'fail' })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Reset Flag'
await composable.submitCreation()
expect(composable.submitting.value).toBe(false)
})
})
// ---------------------------------------------------------------------------
// ProductId from structure
// ---------------------------------------------------------------------------
describe('submitCreation — productId from structure', () => {
beforeEach(() => {
mockHasAssignments.mockReturnValue(false)
mockSerializeStructureAssignments.mockReturnValue(null)
})
it('includes productId in payload when root product selection exists', async () => {
const createdComp = { id: 'comp-prodid-001', name: 'Composant ProductId' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant ProductId'
// Set structure assignments with a root product selection
composable.structureAssignments.value = {
path: 'root',
definition: {} as any,
selectedComponentId: '',
pieces: [],
products: [
{
path: 'root:product-0',
definition: { typeProductId: 'tprod-001' } as any,
selectedProductId: 'prod-selected-123',
},
],
subcomponents: [],
}
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload.productId).toBe('prod-selected-123')
})
it('does not include productId when no root product is selected', async () => {
const createdComp = { id: 'comp-noprodid-001', name: 'Composant No ProductId' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant No ProductId'
composable.structureAssignments.value = {
path: 'root',
definition: {} as any,
selectedComponentId: '',
pieces: [],
products: [],
subcomponents: [],
}
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('productId')
})
it('does not include productId when product selection is empty string', async () => {
const createdComp = { id: 'comp-emptyprod-001', name: 'Composant Empty Product' }
mockCreateComposant.mockResolvedValue({ success: true, data: createdComp })
const composable = useComponentCreate()
composable.selectedTypeId.value = 'tc-moteur'
await new Promise(r => setTimeout(r, 0))
composable.creationForm.name = 'Composant Empty Product'
composable.structureAssignments.value = {
path: 'root',
definition: {} as any,
selectedComponentId: '',
pieces: [],
products: [
{
path: 'root:product-0',
definition: { typeProductId: 'tprod-001' } as any,
selectedProductId: '',
},
],
subcomponents: [],
}
await composable.submitCreation()
const payload = mockCreateComposant.mock.calls[0]![0]
expect(payload).not.toHaveProperty('productId')
})
})

View File

@@ -0,0 +1,890 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
mockComponentFromApi,
mockLinkSKF,
mockLinkFAG,
mockConstructeurSKF,
wrapCollection,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
const mockPostFormData = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: mockPostFormData,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — Toast
// ---------------------------------------------------------------------------
const mockShowSuccess = vi.fn()
const mockShowError = vi.fn()
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: mockShowSuccess,
showError: mockShowError,
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useComposants (updateComposant)
// ---------------------------------------------------------------------------
const mockUpdateComposant = vi.fn()
vi.mock('~/composables/useComposants', () => ({
useComposants: () => ({
updateComposant: mockUpdateComposant,
composants: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePieces, useProducts
// ---------------------------------------------------------------------------
vi.mock('~/composables/usePieces', () => ({
usePieces: () => ({
pieces: { value: [] },
loading: { value: false },
}),
}))
vi.mock('~/composables/useProducts', () => ({
useProducts: () => ({
products: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useComponentTypes, usePieceTypes, useProductTypes
// ---------------------------------------------------------------------------
const mockLoadComponentTypes = vi.fn().mockResolvedValue(undefined)
const mockComponentTypes = { value: [] as any[] }
vi.mock('~/composables/useComponentTypes', () => ({
useComponentTypes: () => ({
componentTypes: mockComponentTypes,
loadComponentTypes: mockLoadComponentTypes,
loadingComponentTypes: { value: false },
}),
}))
vi.mock('~/composables/usePieceTypes', () => ({
usePieceTypes: () => ({
pieceTypes: { value: [] },
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useProductTypes', () => ({
useProductTypes: () => ({
productTypes: { value: [] },
loadProductTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useDocuments
// ---------------------------------------------------------------------------
const mockLoadDocumentsByComponent = vi.fn().mockResolvedValue({ success: true, data: [] })
const mockUploadDocuments = vi.fn().mockResolvedValue({ success: true, data: [] })
const mockDeleteDocument = vi.fn().mockResolvedValue({ success: true })
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
loadDocumentsByComponent: mockLoadDocumentsByComponent,
uploadDocuments: mockUploadDocuments,
deleteDocument: mockDeleteDocument,
documents: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurLinks
// ---------------------------------------------------------------------------
const mockFetchLinks = vi.fn().mockResolvedValue([])
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
vi.mock('~/composables/useConstructeurLinks', () => ({
useConstructeurLinks: () => ({
fetchLinks: mockFetchLinks,
syncLinks: mockSyncLinks,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useCustomFieldInputs
// ---------------------------------------------------------------------------
const mockSaveAll = vi.fn().mockResolvedValue([])
const mockRefreshCF = vi.fn()
vi.mock('~/composables/useCustomFieldInputs', () => ({
useCustomFieldInputs: () => ({
fields: { value: [] },
requiredFilled: { value: true },
saveAll: mockSaveAll,
refresh: mockRefreshCF,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePermissions (auto-imported in Nuxt)
// ---------------------------------------------------------------------------
vi.stubGlobal('usePermissions', () => ({
canEdit: { value: true },
canManage: { value: true },
isAdmin: { value: false },
isGranted: () => true,
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurs
// ---------------------------------------------------------------------------
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useEntityHistory
// ---------------------------------------------------------------------------
vi.mock('~/composables/useEntityHistory', () => ({
useEntityHistory: () => ({
history: { value: [] },
loading: { value: false },
error: { value: null },
loadHistory: vi.fn().mockResolvedValue([]),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — shared utils
// ---------------------------------------------------------------------------
vi.mock('~/shared/utils/structureDisplayUtils', () => ({
getStructurePieces: (s: any) => Array.isArray(s?.pieces) ? s.pieces : [],
getStructureProducts: (s: any) => Array.isArray(s?.products) ? s.products : [],
resolvePieceLabel: (p: any) => p?.name ?? '',
resolveProductLabel: (p: any) => p?.name ?? '',
resolveSubcomponentLabel: (p: any) => p?.name ?? '',
fetchModelTypeNames: vi.fn().mockResolvedValue({}),
buildTypeLabelMap: () => ({}),
}))
vi.mock('~/shared/modelUtils', () => ({
formatStructurePreview: () => '',
normalizeStructureForEditor: (s: any) => s,
}))
vi.mock('~/shared/constructeurUtils', () => ({
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
}))
vi.mock('~/shared/utils/structureSelectionUtils', () => ({
collectStructureSelections: () => ({ pieces: [], products: [], components: [] }),
}))
vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: () => false,
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentEdit } from '~/composables/useComponentEdit'
// ---------------------------------------------------------------------------
// Test data — component with structure containing slots
// ---------------------------------------------------------------------------
const COMPONENT_ID = 'cl-comp-1'
function buildComponentWithStructure() {
return {
...mockComponentFromApi,
id: COMPONENT_ID,
'@id': `/api/composants/${COMPONENT_ID}`,
description: 'Un moteur triphas\u00e9 haute performance',
prix: '1500.00',
typeComposantId: 'tc-moteur',
structure: {
pieces: [
{
slotId: 'ps-001',
typePieceId: 'tp-bearing-001',
selectedPieceId: 'piece-001',
selectedPieceName: 'Roulement 6205',
quantity: 2,
position: 0,
},
{
slotId: 'ps-002',
typePieceId: 'tp-seal-002',
selectedPieceId: 'piece-002',
selectedPieceName: 'Joint torique',
quantity: 1,
position: 1,
},
],
products: [
{
slotId: 'prs-001',
typeProductId: 'tprod-grease-001',
selectedProductId: 'prod-001',
selectedProductName: 'Graisse LGMT2',
familyCode: 'LUB',
position: 0,
},
],
subcomponents: [
{
slotId: 'scs-001',
typeComposantId: 'tc-sub-001',
selectedComponentId: 'comp-sub-001',
selectedComponentName: 'Palier avant',
alias: 'Palier avant',
familyCode: 'PAL',
position: 0,
},
],
customFields: [],
},
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Wait for next tick + micro-tasks so watchers fire. */
const tick = () => new Promise(r => setTimeout(r, 0))
/**
* Create the composable AND hydrate it by resolving the mocked get.
* Returns the composable instance after fetch + watcher hydration.
*/
async function createAndHydrate(overrides?: Partial<ReturnType<typeof buildComponentWithStructure>>) {
const comp = { ...buildComponentWithStructure(), ...overrides }
mockGet.mockImplementation((url: string) => {
if (url.includes(`/composants/${COMPONENT_ID}`)) {
return Promise.resolve({ success: true, data: structuredClone(comp) })
}
return Promise.resolve({ success: true, data: wrapCollection([]) })
})
mockFetchLinks.mockResolvedValue([
{ ...mockLinkSKF },
])
const composable = useComponentEdit(COMPONENT_ID)
// fetchComponent is called, then the watcher hydrates editionForm
await composable.fetchComponent()
await tick()
return composable
}
// ---------------------------------------------------------------------------
// beforeEach
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks()
mockComponentTypes.value = [
{ id: 'tc-moteur', name: 'Moteur \u00e9lectrique', category: 'COMPONENT', structure: null },
]
})
// ---------------------------------------------------------------------------
// fetchComponent — hydration
// ---------------------------------------------------------------------------
describe('fetchComponent — hydration', () => {
it('loads simple fields into editionForm (name, reference, description, prix)', async () => {
const composable = await createAndHydrate()
expect(composable.editionForm.name).toBe('Moteur principal')
expect(composable.editionForm.reference).toBe('COMP-MOT-001')
expect(composable.editionForm.description).toBe('Un moteur triphas\u00e9 haute performance')
expect(composable.editionForm.prix).toBe('1500.00')
})
it('loads component object with structure containing slots', async () => {
const composable = await createAndHydrate()
expect(composable.component.value).not.toBeNull()
expect(composable.component.value.structure).toBeDefined()
expect(composable.component.value.structure.pieces).toHaveLength(2)
expect(composable.component.value.structure.products).toHaveLength(1)
expect(composable.component.value.structure.subcomponents).toHaveLength(1)
expect(composable.component.value.customFieldValues).toBeDefined()
expect(Array.isArray(composable.component.value.customFieldValues)).toBe(true)
})
it('loads constructeur links via fetchLinks', async () => {
const composable = await createAndHydrate()
expect(mockFetchLinks).toHaveBeenCalledWith('composant', COMPONENT_ID)
expect(composable.constructeurLinks.value).toHaveLength(1)
expect(composable.constructeurLinks.value[0].constructeurId).toBe(mockConstructeurSKF.id)
})
})
// ---------------------------------------------------------------------------
// Slot operations — no data loss
// ---------------------------------------------------------------------------
describe('slot operations — no data loss', () => {
it('setting piece slot selection preserves product and subcomponent slots', async () => {
const composable = await createAndHydrate()
// Record initial product and subcomponent slot entries
const initialProductSlots = composable.productSlotEntries.value
const initialSubSlots = composable.subcomponentSlotEntries.value
expect(initialProductSlots).toHaveLength(1)
expect(initialSubSlots).toHaveLength(1)
// Change a piece slot selection
composable.setPieceSlotSelection('ps-001', 'piece-999')
await tick()
// Piece slot changed
const pieceSlots = composable.pieceSlotEntries.value
expect(pieceSlots.find(s => s.slotId === 'ps-001')?.selectedPieceId).toBe('piece-999')
// Product and subcomponent slots untouched
expect(composable.productSlotEntries.value).toHaveLength(1)
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-001')
expect(composable.subcomponentSlotEntries.value).toHaveLength(1)
expect(composable.subcomponentSlotEntries.value[0].selectedComponentId).toBe('comp-sub-001')
})
it('setting product slot selection preserves piece slots', async () => {
const composable = await createAndHydrate()
// Change a product slot
composable.setProductSlotSelection('prs-001', 'prod-new-001')
await tick()
// Product changed
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-new-001')
// Piece slots untouched
expect(composable.pieceSlotEntries.value).toHaveLength(2)
expect(composable.pieceSlotEntries.value[0].selectedPieceId).toBe('piece-001')
expect(composable.pieceSlotEntries.value[1].selectedPieceId).toBe('piece-002')
})
it('setting subcomponent slot selection preserves piece and product slots', async () => {
const composable = await createAndHydrate()
// Change a subcomponent slot
composable.setSubcomponentSlotSelection('scs-001', 'comp-new-sub')
await tick()
// Subcomponent changed
expect(composable.subcomponentSlotEntries.value[0].selectedComponentId).toBe('comp-new-sub')
// Piece and product slots untouched
expect(composable.pieceSlotEntries.value[0].selectedPieceId).toBe('piece-001')
expect(composable.productSlotEntries.value[0].selectedProductId).toBe('prod-001')
})
it('setting slot quantity preserves selectedPieceId', async () => {
const composable = await createAndHydrate()
// Set a piece selection first
composable.setPieceSlotSelection('ps-001', 'piece-special')
await tick()
// Now change quantity on the same slot
composable.setSlotQuantity('ps-001', 5)
await tick()
const slot = composable.pieceSlotEntries.value.find(s => s.slotId === 'ps-001')
expect(slot?.selectedPieceId).toBe('piece-special')
expect(slot?.quantity).toBe(5)
})
})
// ---------------------------------------------------------------------------
// submitEdition — no data loss
// ---------------------------------------------------------------------------
describe('submitEdition — no data loss', () => {
it('sends all form fields in PATCH payload via updateComposant', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
// Modify form fields
composable.editionForm.name = 'Moteur modifi\u00e9'
composable.editionForm.description = 'Nouvelle description'
composable.editionForm.reference = 'REF-MOD-001'
composable.editionForm.prix = '2500'
await composable.submitEdition()
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload).toMatchObject({
name: 'Moteur modifi\u00e9',
description: 'Nouvelle description',
reference: 'REF-MOD-001',
prix: '2500',
})
})
it('saves custom fields after patch', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
expect(mockSaveAll).toHaveBeenCalledTimes(1)
})
it('patches slot edits to correct endpoints', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockPatch.mockResolvedValue({ success: true, data: {} })
const composable = await createAndHydrate()
// Make slot edits
composable.setPieceSlotSelection('ps-001', 'piece-new')
composable.setSlotQuantity('ps-002', 3)
composable.setProductSlotSelection('prs-001', 'prod-new')
composable.setSubcomponentSlotSelection('scs-001', 'comp-new')
await composable.submitEdition()
// Verify piece slot patches
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-new' })
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-002', { quantity: 3 })
// Verify product slot patch
expect(mockPatch).toHaveBeenCalledWith('/composant-product-slots/prs-001', { selectedProductId: 'prod-new' })
// Verify subcomponent slot patch
expect(mockPatch).toHaveBeenCalledWith('/composant-subcomponent-slots/scs-001', { selectedComposantId: 'comp-new' })
})
it('syncs constructeur links with diff', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
// Add a second constructeur link
composable.constructeurLinks.value = [
{ ...mockLinkSKF },
{ ...mockLinkFAG },
]
await composable.submitEdition()
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
// originalConstructeurLinks was set to [mockLinkSKF] from fetchLinks
// formLinks is now [mockLinkSKF, mockLinkFAG]
const [entityType, entityId, origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
expect(entityType).toBe('composant')
expect(entityId).toBe(COMPONENT_ID)
expect(origLinks).toHaveLength(1)
expect(formLinks).toHaveLength(2)
})
it('editing name does not lose constructeur links', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
// Only edit name
composable.editionForm.name = 'Nouveau nom moteur'
await composable.submitEdition()
// updateComposant was called with name change
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.name).toBe('Nouveau nom moteur')
// syncLinks was still called (preserving links)
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
// Both should contain the original SKF link
expect(origLinks).toHaveLength(1)
expect(formLinks).toHaveLength(1)
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
})
})
// ---------------------------------------------------------------------------
// Document operations
// ---------------------------------------------------------------------------
describe('document operations', () => {
it('populates componentDocuments from fetchComponent response', async () => {
const docFixtures = [
{ id: 'doc-1', name: 'photo.jpg', type: 'photo' },
{ id: 'doc-2', name: 'schema.pdf', type: 'schema' },
]
const composable = await createAndHydrate({ documents: docFixtures } as any)
expect(composable.componentDocuments.value).toHaveLength(2)
expect(composable.componentDocuments.value[0].id).toBe('doc-1')
expect(composable.componentDocuments.value[1].id).toBe('doc-2')
})
it('sets componentDocuments to empty array when response has no documents', async () => {
const composable = await createAndHydrate({ documents: undefined } as any)
expect(composable.componentDocuments.value).toEqual([])
})
it('handleFilesAdded calls uploadDocuments with composantId context', async () => {
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: [] })
const composable = await createAndHydrate()
const files = [new File(['content'], 'test.pdf', { type: 'application/pdf' })]
await composable.handleFilesAdded(files)
expect(mockUploadDocuments).toHaveBeenCalledTimes(1)
const callArgs = mockUploadDocuments.mock.calls[0]![0]
expect(callArgs.files).toBe(files)
expect(callArgs.context.composantId).toBe(COMPONENT_ID)
})
it('handleFilesAdded does nothing when files array is empty', async () => {
const composable = await createAndHydrate()
await composable.handleFilesAdded([])
expect(mockUploadDocuments).not.toHaveBeenCalled()
})
it('handleFilesAdded refreshes documents after successful upload', async () => {
const refreshedDocs = [{ id: 'doc-new', name: 'uploaded.pdf' }]
mockUploadDocuments.mockResolvedValue({ success: true, data: [] })
mockLoadDocumentsByComponent.mockResolvedValue({ success: true, data: refreshedDocs })
const composable = await createAndHydrate()
const files = [new File(['data'], 'uploaded.pdf')]
await composable.handleFilesAdded(files)
expect(mockLoadDocumentsByComponent).toHaveBeenCalledWith(COMPONENT_ID, { updateStore: false })
expect(composable.componentDocuments.value).toHaveLength(1)
expect(composable.componentDocuments.value[0].id).toBe('doc-new')
})
it('removeDocument calls deleteDocument and removes from local list', async () => {
mockDeleteDocument.mockResolvedValue({ success: true })
const composable = await createAndHydrate({
documents: [
{ id: 'doc-a', name: 'a.pdf' },
{ id: 'doc-b', name: 'b.pdf' },
],
} as any)
expect(composable.componentDocuments.value).toHaveLength(2)
await composable.removeDocument('doc-a')
expect(mockDeleteDocument).toHaveBeenCalledWith('doc-a', { updateStore: false })
expect(composable.componentDocuments.value).toHaveLength(1)
expect(composable.componentDocuments.value[0].id).toBe('doc-b')
})
it('removeDocument does nothing when documentId is falsy', async () => {
const composable = await createAndHydrate()
await composable.removeDocument(null)
await composable.removeDocument(undefined)
expect(mockDeleteDocument).not.toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// Null field handling in PATCH payload
// ---------------------------------------------------------------------------
describe('null field handling in PATCH payload', () => {
it('empty prix string sends null in payload', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = ''
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.prix).toBeNull()
})
it('whitespace-only prix string sends null in payload', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = ' '
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.prix).toBeNull()
})
it('valid prix string sends stringified number', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = '42.50'
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.prix).toBe('42.5')
})
it('empty reference string sends null in payload', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.reference = ''
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.reference).toBeNull()
})
it('empty description string sends null in payload', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.description = ''
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.description).toBeNull()
})
it('whitespace-only description sends null in payload', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.description = ' '
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.description).toBeNull()
})
it('name is trimmed but never null', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
const composable = await createAndHydrate()
composable.editionForm.name = ' Moteur '
await composable.submitEdition()
const payload = mockUpdateComposant.mock.calls[0]![1]
expect(payload.name).toBe('Moteur')
})
})
// ---------------------------------------------------------------------------
// Error paths
// ---------------------------------------------------------------------------
describe('error paths', () => {
it('does not save custom fields when updateComposant returns { success: false }', async () => {
mockUpdateComposant.mockResolvedValue({ success: false })
const composable = await createAndHydrate()
composable.editionForm.name = 'Test'
await composable.submitEdition()
expect(mockUpdateComposant).toHaveBeenCalledTimes(1)
expect(mockSaveAll).not.toHaveBeenCalled()
expect(mockPatch).not.toHaveBeenCalled()
expect(mockSyncLinks).not.toHaveBeenCalled()
})
it('does not patch slots when updateComposant returns { success: false }', async () => {
mockUpdateComposant.mockResolvedValue({ success: false })
const composable = await createAndHydrate()
composable.setPieceSlotSelection('ps-001', 'piece-new')
composable.setProductSlotSelection('prs-001', 'prod-new')
await composable.submitEdition()
expect(mockPatch).not.toHaveBeenCalled()
})
it('does not sync constructeur links when updateComposant fails', async () => {
mockUpdateComposant.mockResolvedValue({ success: false })
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockSyncLinks).not.toHaveBeenCalled()
})
it('shows error toast when saveAllCustomFields returns failed fields', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockSaveAll.mockResolvedValue(['Tension nominale', 'Indice de protection'])
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockShowError).toHaveBeenCalledTimes(1)
expect(mockShowError.mock.calls[0]![0]).toContain('Tension nominale')
expect(mockShowError.mock.calls[0]![0]).toContain('Indice de protection')
})
it('still saves slots and syncs links even when custom fields fail', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockSaveAll.mockResolvedValue(['Tension nominale'])
mockPatch.mockResolvedValue({ success: true, data: {} })
const composable = await createAndHydrate()
composable.setPieceSlotSelection('ps-001', 'piece-after-cf-fail')
await composable.submitEdition()
// Slots still patched despite custom field failure
expect(mockPatch).toHaveBeenCalledWith('/composant-piece-slots/ps-001', { selectedPieceId: 'piece-after-cf-fail' })
// Links still synced
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
// Success toast still shown (alongside the error toast for CF)
expect(mockShowSuccess).toHaveBeenCalledTimes(1)
})
it('shows error toast when submitEdition throws', async () => {
mockUpdateComposant.mockRejectedValue(new Error('Network failure'))
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockShowError).toHaveBeenCalledTimes(1)
expect(mockShowError.mock.calls[0]![0]).toContain('Network failure')
expect(composable.saving.value).toBe(false)
})
it('resets saving flag even when updateComposant throws', async () => {
mockUpdateComposant.mockRejectedValue(new Error('Server error'))
const composable = await createAndHydrate()
await composable.submitEdition()
expect(composable.saving.value).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Custom field save verification
// ---------------------------------------------------------------------------
describe('custom field save verification', () => {
it('saveAllCustomFields is called after successful updateComposant', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockSaveAll.mockResolvedValue([])
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockSaveAll).toHaveBeenCalledTimes(1)
})
it('does not show error toast when saveAll returns empty array (no failures)', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockSaveAll.mockResolvedValue([])
const composable = await createAndHydrate()
await composable.submitEdition()
// showError should NOT have been called (only showSuccess)
expect(mockShowError).not.toHaveBeenCalled()
expect(mockShowSuccess).toHaveBeenCalledTimes(1)
})
it('shows error with all failed field names joined', async () => {
mockUpdateComposant.mockResolvedValue({ success: true, data: { id: COMPONENT_ID } })
mockSaveAll.mockResolvedValue(['Champ A', 'Champ B', 'Champ C'])
const composable = await createAndHydrate()
await composable.submitEdition()
expect(mockShowError).toHaveBeenCalledTimes(1)
const errorMsg = mockShowError.mock.calls[0]![0] as string
expect(errorMsg).toContain('Champ A')
expect(errorMsg).toContain('Champ B')
expect(errorMsg).toContain('Champ C')
})
it('submitEdition does nothing when component is null', async () => {
const composable = await createAndHydrate()
// Force component to null
composable.component.value = null
await composable.submitEdition()
expect(mockUpdateComposant).not.toHaveBeenCalled()
expect(mockSaveAll).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useComposants } from '~/composables/useComposants'
import { mockComponentFromApi, wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
const { clearComposantsCache } = useComposants()
clearComposantsCache()
})
// ---------------------------------------------------------------------------
// createComposant
// ---------------------------------------------------------------------------
describe('createComposant', () => {
it('sends all fields in creation payload', async () => {
const created = { ...mockComponentFromApi, id: 'comp-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createComposant } = useComposants()
const result = await createComposant({
name: 'Moteur principal',
reference: 'COMP-MOT-001',
description: 'Un moteur',
typeComposantId: 'tc-moteur',
})
expect(result.success).toBe(true)
// normalizeRelationIds converts typeComposantId to typeComposant IRI
expect(mockPost).toHaveBeenCalledWith('/composants', expect.objectContaining({
name: 'Moteur principal',
reference: 'COMP-MOT-001',
description: 'Un moteur',
typeComposant: '/api/model_types/tc-moteur',
}))
// typeComposantId should be removed by normalizeRelationIds
const payload = mockPost.mock.calls[0]![1]
expect(payload).not.toHaveProperty('typeComposantId')
})
it('adds created composant to cache', async () => {
const created = { ...mockComponentFromApi, id: 'comp-new', name: 'Nouveau composant' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createComposant, composants, total } = useComposants()
expect(composants.value).toHaveLength(0)
expect(total.value).toBe(0)
await createComposant({ name: 'Nouveau composant' })
expect(composants.value).toHaveLength(1)
expect(composants.value[0]!.id).toBe('comp-new')
expect(total.value).toBe(1)
})
})
// ---------------------------------------------------------------------------
// updateComposant
// ---------------------------------------------------------------------------
describe('updateComposant', () => {
it('patches and updates cache', async () => {
// Seed the cache with one composant
const original = { ...mockComponentFromApi, id: 'comp-001', name: 'Ancien nom' }
mockPost.mockResolvedValue({ success: true, data: original })
const { createComposant, updateComposant, composants } = useComposants()
await createComposant({ name: 'Ancien nom' })
expect(composants.value).toHaveLength(1)
// Now update
const updated = { ...original, name: 'Nouveau nom' }
mockPatch.mockResolvedValue({ success: true, data: updated })
const result = await updateComposant('comp-001', { name: 'Nouveau nom' })
expect(result.success).toBe(true)
expect(mockPatch).toHaveBeenCalledWith('/composants/comp-001', expect.objectContaining({
name: 'Nouveau nom',
}))
expect(composants.value[0]!.name).toBe('Nouveau nom')
})
})
// ---------------------------------------------------------------------------
// deleteComposant
// ---------------------------------------------------------------------------
describe('deleteComposant', () => {
it('removes composant from cache on success', async () => {
// Seed cache
const item = { ...mockComponentFromApi, id: 'comp-del', name: 'A supprimer' }
mockPost.mockResolvedValue({ success: true, data: item })
const { createComposant, deleteComposant, composants, total } = useComposants()
await createComposant({ name: 'A supprimer' })
expect(composants.value).toHaveLength(1)
expect(total.value).toBe(1)
mockDel.mockResolvedValue({ success: true })
const result = await deleteComposant('comp-del')
expect(result.success).toBe(true)
expect(composants.value).toHaveLength(0)
expect(total.value).toBe(0)
})
it('keeps composant in cache on failure', async () => {
// Seed cache
const item = { ...mockComponentFromApi, id: 'comp-keep', name: 'Garder' }
mockPost.mockResolvedValue({ success: true, data: item })
const { createComposant, deleteComposant, composants, total } = useComposants()
await createComposant({ name: 'Garder' })
expect(composants.value).toHaveLength(1)
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
const result = await deleteComposant('comp-keep')
expect(result.success).toBe(false)
expect(composants.value).toHaveLength(1)
expect(composants.value[0]!.id).toBe('comp-keep')
})
})

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import {
mockLinkSKF,
mockLinkFAG,
mockConstructeurSKF,
mockConstructeurFAG,
wrapCollection,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: vi.fn(),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// fetchLinks
// ---------------------------------------------------------------------------
describe('fetchLinks', () => {
it('returns parsed links with all properties for composant', async () => {
const apiLinks = [
{
id: mockLinkSKF.linkId,
constructeur: mockConstructeurSKF,
supplierReference: mockLinkSKF.supplierReference,
},
{
id: mockLinkFAG.linkId,
constructeur: mockConstructeurFAG,
supplierReference: mockLinkFAG.supplierReference,
},
]
mockGet.mockResolvedValue({ success: true, data: wrapCollection(apiLinks) })
const { fetchLinks } = useConstructeurLinks()
const result = await fetchLinks('composant', 'comp-001')
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
linkId: mockLinkSKF.linkId,
constructeurId: mockConstructeurSKF.id,
constructeur: mockConstructeurSKF,
supplierReference: mockLinkSKF.supplierReference,
})
expect(result[1]).toEqual({
linkId: mockLinkFAG.linkId,
constructeurId: mockConstructeurFAG.id,
constructeur: mockConstructeurFAG,
supplierReference: mockLinkFAG.supplierReference,
})
})
it('returns supplierReference as null when absent from API', async () => {
const apiLinks = [
{
id: 'link-no-ref',
constructeur: mockConstructeurSKF,
// no supplierReference key
},
]
mockGet.mockResolvedValue({ success: true, data: wrapCollection(apiLinks) })
const { fetchLinks } = useConstructeurLinks()
const result = await fetchLinks('composant', 'comp-001')
expect(result).toHaveLength(1)
expect(result[0]!.supplierReference).toBeNull()
})
it.each([
['machine', '/machine_constructeur_links?machine=/api/machines/m-001', 'm-001'],
['product', '/product_constructeur_links?product=/api/products/p-001', 'p-001'],
['piece', '/piece_constructeur_links?piece=/api/pieces/pc-001', 'pc-001'],
['composant', '/composant_constructeur_links?composant=/api/composants/c-001', 'c-001'],
] as const)('uses correct endpoint for %s', async (entityType, expectedUrl, entityId) => {
mockGet.mockResolvedValue({ success: true, data: wrapCollection([]) })
const { fetchLinks } = useConstructeurLinks()
await fetchLinks(entityType, entityId)
expect(mockGet).toHaveBeenCalledWith(expectedUrl)
})
it('returns empty array on API failure', async () => {
mockGet.mockResolvedValue({ success: false, data: null })
const { fetchLinks } = useConstructeurLinks()
const result = await fetchLinks('composant', 'comp-001')
expect(result).toEqual([])
})
})
// ---------------------------------------------------------------------------
// syncLinks — 3-way diff
// ---------------------------------------------------------------------------
describe('syncLinks', () => {
it('creates new links via POST', async () => {
mockPost.mockResolvedValue({ success: true })
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [], [mockLinkSKF])
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
composant: '/api/composants/comp-001',
constructeur: `/api/constructeurs/${mockConstructeurSKF.id}`,
supplierReference: mockLinkSKF.supplierReference,
})
expect(mockDel).not.toHaveBeenCalled()
expect(mockPatch).not.toHaveBeenCalled()
})
it('deletes removed links via DELETE', async () => {
mockDel.mockResolvedValue({ success: true })
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [mockLinkSKF], [])
expect(mockDel).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`)
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).not.toHaveBeenCalled()
})
it('patches when supplierReference changes (value to new value)', async () => {
mockPatch.mockResolvedValue({ success: true })
const updatedLink = { ...mockLinkSKF, supplierReference: 'NEW-REF-999' }
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [mockLinkSKF], [updatedLink])
expect(mockPatch).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`, {
supplierReference: 'NEW-REF-999',
})
expect(mockPost).not.toHaveBeenCalled()
expect(mockDel).not.toHaveBeenCalled()
})
it('patches when supplierReference changes from value to null', async () => {
mockPatch.mockResolvedValue({ success: true })
const updatedLink = { ...mockLinkSKF, supplierReference: null }
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [mockLinkSKF], [updatedLink])
expect(mockPatch).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`, {
supplierReference: null,
})
})
it('does nothing when links are identical (no API calls)', async () => {
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [mockLinkSKF], [mockLinkSKF])
expect(mockPost).not.toHaveBeenCalled()
expect(mockDel).not.toHaveBeenCalled()
expect(mockPatch).not.toHaveBeenCalled()
})
it('handles add + delete in same operation', async () => {
mockPost.mockResolvedValue({ success: true })
mockDel.mockResolvedValue({ success: true })
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [mockLinkSKF], [mockLinkFAG])
expect(mockDel).toHaveBeenCalledWith(`/composant_constructeur_links/${mockLinkSKF.linkId}`)
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
composant: '/api/composants/comp-001',
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
supplierReference: mockLinkFAG.supplierReference,
})
expect(mockPatch).not.toHaveBeenCalled()
})
it('handles empty original and empty form (no-op)', async () => {
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [], [])
expect(mockPost).not.toHaveBeenCalled()
expect(mockDel).not.toHaveBeenCalled()
expect(mockPatch).not.toHaveBeenCalled()
})
it('sends supplierReference as null when empty string', async () => {
mockPost.mockResolvedValue({ success: true })
const linkWithEmpty = { ...mockLinkFAG, supplierReference: '' }
const { syncLinks } = useConstructeurLinks()
await syncLinks('composant', 'comp-001', [], [linkWithEmpty])
expect(mockPost).toHaveBeenCalledWith('/composant_constructeur_links', {
composant: '/api/composants/comp-001',
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
supplierReference: null,
})
})
it.each([
['machine', '/machine_constructeur_links', 'machine', '/api/machines/m-001', 'm-001'],
['product', '/product_constructeur_links', 'product', '/api/products/p-001', 'p-001'],
['piece', '/piece_constructeur_links', 'piece', '/api/pieces/pc-001', 'pc-001'],
['composant', '/composant_constructeur_links', 'composant', '/api/composants/c-001', 'c-001'],
] as const)('uses correct endpoint and entity IRI for %s', async (entityType, endpoint, key, entityIri, entityId) => {
mockPost.mockResolvedValue({ success: true })
mockDel.mockResolvedValue({ success: true })
const { syncLinks } = useConstructeurLinks()
await syncLinks(entityType, entityId, [mockLinkSKF], [mockLinkFAG])
expect(mockDel).toHaveBeenCalledWith(`${endpoint}/${mockLinkSKF.linkId}`)
expect(mockPost).toHaveBeenCalledWith(endpoint, {
[key]: entityIri,
constructeur: `/api/constructeurs/${mockConstructeurFAG.id}`,
supplierReference: mockLinkFAG.supplierReference,
})
})
})

View File

@@ -0,0 +1,475 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
import {
shouldPersist,
formatValueForSave,
} from '~/shared/utils/customFields'
import {
mockCustomFieldDefs,
mockCustomFieldValues,
mockMachineCustomFieldDefs,
mockMachineCustomFieldValues,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockUpdateCustomFieldValue = vi.fn()
const mockUpsertCustomFieldValue = vi.fn()
vi.mock('~/composables/useCustomFields', () => ({
useCustomFields: () => ({
updateCustomFieldValue: mockUpdateCustomFieldValue,
upsertCustomFieldValue: mockUpsertCustomFieldValue,
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
mockUpdateCustomFieldValue.mockResolvedValue({ success: true })
mockUpsertCustomFieldValue.mockResolvedValue({ success: true, data: { id: 'new-cfv-id', customField: { id: 'new-cf-id' } } })
})
// ---------------------------------------------------------------------------
// Field initialization
// ---------------------------------------------------------------------------
describe('field initialization', () => {
it('merges all definitions with their values (6 defs → 6 allFields, 5 standalone fields)', () => {
const { fields, allFields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
expect(allFields.value).toHaveLength(6)
expect(fields.value).toHaveLength(5)
})
it('preserves value for number type', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const numberField = fields.value.find(f => f.name === 'Tension nominale')
expect(numberField?.value).toBe('220')
expect(numberField?.type).toBe('number')
})
it('preserves value for boolean type', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const boolField = fields.value.find(f => f.name === 'Certifié CE')
expect(boolField?.value).toBe('true')
expect(boolField?.type).toBe('boolean')
})
it('preserves value for select type with options', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const selectField = fields.value.find(f => f.name === 'Indice de protection')
expect(selectField?.value).toBe('IP65')
expect(selectField?.type).toBe('select')
expect(selectField?.options).toEqual(['IP54', 'IP55', 'IP65'])
})
it('preserves value for date type', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const dateField = fields.value.find(f => f.name === 'Date de calibration')
expect(dateField?.value).toBe('2025-06-15')
expect(dateField?.type).toBe('date')
})
it('preserves value for text type', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const textField = fields.value.find(f => f.name === 'Remarques techniques')
expect(textField?.value).toBe('Roulement renforcé pour environnement humide')
expect(textField?.type).toBe('text')
})
it('uses defaultValue when no persisted value exists', () => {
// Pass empty values array so all fields use defaultValue
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref([]),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const numberField = fields.value.find(f => f.name === 'Tension nominale')
expect(numberField?.value).toBe('220')
const boolField = fields.value.find(f => f.name === 'Certifié CE')
expect(boolField?.value).toBe('false')
// No defaultValue → empty string
const dateField = fields.value.find(f => f.name === 'Date de calibration')
expect(dateField?.value).toBe('')
})
it('filters machineContextOnly in standalone context (allFields=6, fields=5)', () => {
const { fields, allFields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
expect(allFields.value).toHaveLength(6)
expect(fields.value).toHaveLength(5)
expect(fields.value.every(f => !f.machineContextOnly)).toBe(true)
})
it('shows only machineContextOnly in machine context (1 field)', () => {
const { fields } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'machine',
})
expect(fields.value).toHaveLength(1)
expect(fields.value[0]?.name).toBe('Position sur machine')
expect(fields.value[0]?.machineContextOnly).toBe(true)
})
})
// ---------------------------------------------------------------------------
// Boolean — the tricky case
// ---------------------------------------------------------------------------
describe('boolean — the tricky case', () => {
it('saves "false" value via update (not ignored)', async () => {
const { fields, update } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const boolField = fields.value.find(f => f.name === 'Certifié CE')!
boolField.value = 'false'
await update(boolField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' })
})
it('persists boolean "false" in saveAll (not skipped)', async () => {
// Only provide the boolean field def + value
const boolDef = mockCustomFieldDefs[1]!
const boolVal = { ...mockCustomFieldValues[1]!, value: 'false' }
const { fields, saveAll } = useCustomFieldInputs({
definitions: ref([boolDef]),
values: ref([boolVal]),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
expect(fields.value[0]?.value).toBe('false')
const failed = await saveAll()
expect(failed).toEqual([])
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-002', { value: 'false' })
})
})
// ---------------------------------------------------------------------------
// Number zero
// ---------------------------------------------------------------------------
describe('number zero', () => {
it('saves "0" value (not ignored)', async () => {
const { fields, update } = useCustomFieldInputs({
definitions: ref(mockMachineCustomFieldDefs),
values: ref(mockMachineCustomFieldValues),
entityType: 'machine',
entityId: ref('cl-machine-1'),
context: 'standalone',
})
const numField = fields.value.find(f => f.name === 'Puissance (kW)')!
expect(numField.value).toBe('0')
await update(numField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-003', { value: '0' })
})
})
// ---------------------------------------------------------------------------
// Text empty string
// ---------------------------------------------------------------------------
describe('text empty string', () => {
it('shouldPersist returns false for empty trimmed string', () => {
const field = {
customFieldId: 'cf-1',
customFieldValueId: null,
name: 'Notes',
type: 'text',
required: false,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
value: ' ',
}
expect(shouldPersist(field)).toBe(false)
})
it('persists non-empty text value', () => {
const field = {
customFieldId: 'cf-1',
customFieldValueId: null,
name: 'Notes',
type: 'text',
required: false,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
value: 'some text',
}
expect(shouldPersist(field)).toBe(true)
expect(formatValueForSave(field)).toBe('some text')
})
})
// ---------------------------------------------------------------------------
// Select
// ---------------------------------------------------------------------------
describe('select', () => {
it('saves changed option value', async () => {
const { fields, update } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const selectField = fields.value.find(f => f.name === 'Indice de protection')!
selectField.value = 'IP55'
await update(selectField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-003', { value: 'IP55' })
})
})
// ---------------------------------------------------------------------------
// Date
// ---------------------------------------------------------------------------
describe('date', () => {
it('saves date value in correct format', async () => {
const { fields, update } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const dateField = fields.value.find(f => f.name === 'Date de calibration')!
dateField.value = '2026-01-20'
await update(dateField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('cfv-004', { value: '2026-01-20' })
})
})
// ---------------------------------------------------------------------------
// saveAll isolation
// ---------------------------------------------------------------------------
describe('saveAll isolation', () => {
it('saves all fields independently without losing values', async () => {
const { fields, saveAll } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
// Modify one field
const numberField = fields.value.find(f => f.name === 'Tension nominale')!
numberField.value = '380'
const failed = await saveAll()
expect(failed).toEqual([])
// All persistable fields should have been saved
// 5 fields in standalone context, all have values
expect(mockUpdateCustomFieldValue.mock.calls.length).toBeGreaterThanOrEqual(4)
// The modified field should have the new value
const numberCall = mockUpdateCustomFieldValue.mock.calls.find(
(c: any[]) => c[0] === 'cfv-001',
)
expect(numberCall?.[1]).toEqual({ value: '380' })
// Another field should still have its original value
const boolCall = mockUpdateCustomFieldValue.mock.calls.find(
(c: any[]) => c[0] === 'cfv-002',
)
expect(boolCall?.[1]).toEqual({ value: 'true' })
})
it('upserts new value when no customFieldValueId exists', async () => {
// Use defs without matching values — no customFieldValueId
const defs = [mockCustomFieldDefs[0]!]
const { saveAll } = useCustomFieldInputs({
definitions: ref(defs),
values: ref([]),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const failed = await saveAll()
expect(failed).toEqual([])
// Should use upsert since no customFieldValueId
expect(mockUpsertCustomFieldValue).toHaveBeenCalledWith(
'cf-def-001',
'composant',
'cl-comp-1',
'220',
undefined,
)
})
it('returns failed field names on error', async () => {
mockUpdateCustomFieldValue.mockResolvedValueOnce({ success: false })
const defs = [mockCustomFieldDefs[0]!]
const vals = [mockCustomFieldValues[0]!]
const { saveAll } = useCustomFieldInputs({
definitions: ref(defs),
values: ref(vals),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
const failed = await saveAll()
expect(failed).toEqual(['Tension nominale'])
})
})
// ---------------------------------------------------------------------------
// requiredFilled validation
// ---------------------------------------------------------------------------
describe('requiredFilled validation', () => {
it('returns true when required fields have values (including defaultValue)', () => {
const { requiredFilled } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref(mockCustomFieldValues),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
// "Tension nominale" is required and has value '220'
expect(requiredFilled.value).toBe(true)
})
it('returns true when required field uses defaultValue', () => {
// No values provided — required field should use defaultValue '220'
const { requiredFilled } = useCustomFieldInputs({
definitions: ref(mockCustomFieldDefs),
values: ref([]),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
expect(requiredFilled.value).toBe(true)
})
it('returns false when required field has no value and no default', () => {
// Create a required field with no default and no value
const defs = [{
id: 'cf-required-no-default',
name: 'Required Field',
type: 'text',
required: true,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
}]
const { requiredFilled } = useCustomFieldInputs({
definitions: ref(defs),
values: ref([]),
entityType: 'composant',
entityId: ref('cl-comp-1'),
context: 'standalone',
})
expect(requiredFilled.value).toBe(false)
})
})

View File

@@ -0,0 +1,319 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPatch = vi.fn()
const mockPostFormData = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: vi.fn(),
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: mockPostFormData,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — Toast
// ---------------------------------------------------------------------------
const mockShowSuccess = vi.fn()
const mockShowError = vi.fn()
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: mockShowSuccess,
showError: mockShowError,
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useDocuments } from '~/composables/useDocuments'
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const mockDocument = {
id: 'doc-001',
name: 'photo.jpg',
filename: 'photo.jpg',
mimeType: 'image/jpeg',
size: 12345,
fileUrl: '/files/photo.jpg',
downloadUrl: '/files/photo.jpg/download',
createdAt: '2025-01-10T08:00:00+00:00',
}
const mockDocument2 = {
id: 'doc-002',
name: 'schema.pdf',
filename: 'schema.pdf',
mimeType: 'application/pdf',
size: 54321,
fileUrl: '/files/schema.pdf',
downloadUrl: '/files/schema.pdf/download',
createdAt: '2025-01-11T09:00:00+00:00',
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createMockFile(name: string, type = 'image/jpeg'): File {
return new File(['content'], name, { type })
}
// ---------------------------------------------------------------------------
// beforeEach
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// uploadDocuments — FormData is built correctly
// ---------------------------------------------------------------------------
describe('uploadDocuments', () => {
it('builds FormData with file and context fields', async () => {
mockPostFormData.mockResolvedValue({ success: true, data: mockDocument })
const { uploadDocuments } = useDocuments()
const file = createMockFile('photo.jpg')
await uploadDocuments({
files: [file],
context: { pieceId: 'piece-001', composantId: 'comp-001' },
})
expect(mockPostFormData).toHaveBeenCalledTimes(1)
const [endpoint, formData] = mockPostFormData.mock.calls[0]!
expect(endpoint).toBe('/documents')
expect(formData).toBeInstanceOf(FormData)
expect(formData.get('file')).toBe(file)
expect(formData.get('name')).toBe('photo.jpg')
expect(formData.get('pieceId')).toBe('piece-001')
expect(formData.get('composantId')).toBe('comp-001')
})
it('uploads multiple files separately', async () => {
mockPostFormData
.mockResolvedValueOnce({ success: true, data: mockDocument })
.mockResolvedValueOnce({ success: true, data: mockDocument2 })
const { uploadDocuments } = useDocuments()
const file1 = createMockFile('photo.jpg')
const file2 = createMockFile('schema.pdf', 'application/pdf')
const result = await uploadDocuments({
files: [file1, file2],
context: { machineId: 'machine-001' },
})
expect(mockPostFormData).toHaveBeenCalledTimes(2)
// First call
const [, formData1] = mockPostFormData.mock.calls[0]!
expect(formData1.get('name')).toBe('photo.jpg')
expect(formData1.get('machineId')).toBe('machine-001')
// Second call
const [, formData2] = mockPostFormData.mock.calls[1]!
expect(formData2.get('name')).toBe('schema.pdf')
expect(formData2.get('machineId')).toBe('machine-001')
expect(result.success).toBe(true)
expect(Array.isArray(result.data) ? result.data : []).toHaveLength(2)
})
it('appends type to FormData when provided in context', async () => {
mockPostFormData.mockResolvedValue({ success: true, data: mockDocument })
const { uploadDocuments } = useDocuments()
const file = createMockFile('facture.pdf', 'application/pdf')
await uploadDocuments({
files: [file],
context: { siteId: 'site-001', type: 'facture' },
})
const [, formData] = mockPostFormData.mock.calls[0]!
expect(formData.get('type')).toBe('facture')
expect(formData.get('siteId')).toBe('site-001')
})
it('returns error when no files provided', async () => {
const { uploadDocuments } = useDocuments()
const result = await uploadDocuments({ files: [], context: {} })
expect(result.success).toBe(false)
expect(mockPostFormData).not.toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// loadDocumentsByComponent
// ---------------------------------------------------------------------------
describe('loadDocumentsByComponent', () => {
it('calls correct endpoint /documents/composant/{id}', async () => {
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
const { loadDocumentsByComponent } = useDocuments()
const result = await loadDocumentsByComponent('comp-001')
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith('/documents/composant/comp-001')
expect(result.success).toBe(true)
})
it('returns error for empty componentId', async () => {
const { loadDocumentsByComponent } = useDocuments()
const result = await loadDocumentsByComponent('')
expect(mockGet).not.toHaveBeenCalled()
expect(result.success).toBe(false)
})
})
// ---------------------------------------------------------------------------
// loadDocumentsByPiece
// ---------------------------------------------------------------------------
describe('loadDocumentsByPiece', () => {
it('calls correct endpoint /documents/piece/{id}', async () => {
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
const { loadDocumentsByPiece } = useDocuments()
const result = await loadDocumentsByPiece('piece-001')
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith('/documents/piece/piece-001')
expect(result.success).toBe(true)
})
it('returns error for empty pieceId', async () => {
const { loadDocumentsByPiece } = useDocuments()
const result = await loadDocumentsByPiece('')
expect(mockGet).not.toHaveBeenCalled()
expect(result.success).toBe(false)
})
})
// ---------------------------------------------------------------------------
// loadDocumentsByMachine
// ---------------------------------------------------------------------------
describe('loadDocumentsByMachine', () => {
it('calls correct endpoint /documents/machine/{id}', async () => {
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
const { loadDocumentsByMachine } = useDocuments()
const result = await loadDocumentsByMachine('machine-001')
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith('/documents/machine/machine-001')
expect(result.success).toBe(true)
})
it('returns error for empty machineId', async () => {
const { loadDocumentsByMachine } = useDocuments()
const result = await loadDocumentsByMachine('')
expect(mockGet).not.toHaveBeenCalled()
expect(result.success).toBe(false)
})
})
// ---------------------------------------------------------------------------
// loadDocumentsByProduct
// ---------------------------------------------------------------------------
describe('loadDocumentsByProduct', () => {
it('calls correct endpoint /documents/product/{id}', async () => {
mockGet.mockResolvedValue({ success: true, data: wrapCollection([mockDocument]) })
const { loadDocumentsByProduct } = useDocuments()
const result = await loadDocumentsByProduct('prod-001')
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith('/documents/product/prod-001')
expect(result.success).toBe(true)
})
it('returns error for empty productId', async () => {
const { loadDocumentsByProduct } = useDocuments()
const result = await loadDocumentsByProduct('')
expect(mockGet).not.toHaveBeenCalled()
expect(result.success).toBe(false)
})
})
// ---------------------------------------------------------------------------
// deleteDocument
// ---------------------------------------------------------------------------
describe('deleteDocument', () => {
it('calls DELETE on correct endpoint', async () => {
mockDel.mockResolvedValue({ success: true })
const { deleteDocument } = useDocuments()
const result = await deleteDocument('doc-001')
expect(mockDel).toHaveBeenCalledTimes(1)
expect(mockDel).toHaveBeenCalledWith('/documents/doc-001')
expect(result.success).toBe(true)
})
it('removes from store when updateStore is true', async () => {
mockGet.mockResolvedValue({
success: true,
data: wrapCollection([mockDocument, mockDocument2]),
})
mockDel.mockResolvedValue({ success: true })
const { loadDocuments, deleteDocument, documents } = useDocuments()
// Load documents into store first
await loadDocuments({ force: true })
expect(documents.value).toHaveLength(2)
// Delete with updateStore: true
await deleteDocument('doc-001', { updateStore: true })
expect(documents.value).toHaveLength(1)
expect(documents.value[0]!.id).toBe('doc-002')
})
it('shows success toast on successful delete', async () => {
mockDel.mockResolvedValue({ success: true })
const { deleteDocument } = useDocuments()
await deleteDocument('doc-001')
expect(mockShowSuccess).toHaveBeenCalledWith('Document supprimé')
})
})

View File

@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { useCustomFieldInputs } from '~/composables/useCustomFieldInputs'
import {
mockMachineCustomFieldDefs,
mockMachineCustomFieldValues,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockUpdateCustomFieldValue = vi.fn()
const mockUpsertCustomFieldValue = vi.fn()
vi.mock('~/composables/useCustomFields', () => ({
useCustomFields: () => ({
updateCustomFieldValue: mockUpdateCustomFieldValue,
upsertCustomFieldValue: mockUpsertCustomFieldValue,
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
mockUpdateCustomFieldValue.mockResolvedValue({ success: true })
mockUpsertCustomFieldValue.mockResolvedValue({
success: true,
data: { id: 'new-mcfv-id', customField: { id: 'new-mcf-id' } },
})
})
// ---------------------------------------------------------------------------
// Helper — create composable with machine context (no context filter)
// ---------------------------------------------------------------------------
function createMachineFields(
defs = mockMachineCustomFieldDefs,
vals = mockMachineCustomFieldValues,
entityId = 'cl-machine-1',
) {
return useCustomFieldInputs({
definitions: ref(defs),
values: ref(vals),
entityType: 'machine',
entityId: ref(entityId),
// No context — machine custom fields don't use machineContextOnly filtering
})
}
// ---------------------------------------------------------------------------
// Machine custom field initialization
// ---------------------------------------------------------------------------
describe('machine custom field initialization', () => {
it('loads all machine custom fields with values (5 fields)', () => {
const { fields } = createMachineFields()
expect(fields.value).toHaveLength(5)
})
it('preserves text value (Numéro de série)', () => {
const { fields } = createMachineFields()
const textField = fields.value.find(f => f.name === 'Numéro de série')
expect(textField?.value).toBe('SN-2025-001234')
expect(textField?.type).toBe('text')
})
it('preserves boolean value (En service = true)', () => {
const { fields } = createMachineFields()
const boolField = fields.value.find(f => f.name === 'En service')
expect(boolField?.value).toBe('true')
expect(boolField?.type).toBe('boolean')
})
it('preserves number zero value (Puissance kW = 0)', () => {
const { fields } = createMachineFields()
const numField = fields.value.find(f => f.name === 'Puissance (kW)')
expect(numField?.value).toBe('0')
expect(numField?.type).toBe('number')
})
it('preserves select value (Catégorie ATEX = Zone 1)', () => {
const { fields } = createMachineFields()
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')
expect(selectField?.value).toBe('Zone 1')
expect(selectField?.type).toBe('select')
expect(selectField?.options).toEqual(['Zone 0', 'Zone 1', 'Zone 2', 'Non classé'])
})
it('preserves date value (Date mise en service = 2025-01-15)', () => {
const { fields } = createMachineFields()
const dateField = fields.value.find(f => f.name === 'Date mise en service')
expect(dateField?.value).toBe('2025-01-15')
expect(dateField?.type).toBe('date')
})
})
// ---------------------------------------------------------------------------
// Boolean checkbox — the critical test
// ---------------------------------------------------------------------------
describe('boolean checkbox — the critical test', () => {
it('toggle true to false sends "false" (not deleted) via update()', async () => {
const { fields, update } = createMachineFields()
const boolField = fields.value.find(f => f.name === 'En service')!
expect(boolField.value).toBe('true')
// Toggle to false
boolField.value = 'false'
await update(boolField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'false' })
})
it('toggle false to true sends "true"', async () => {
// Start with boolean value = false
const falseVal = { ...mockMachineCustomFieldValues[1]!, value: 'false' }
const vals = mockMachineCustomFieldValues.map((v, i) => (i === 1 ? falseVal : v))
const { fields, update } = createMachineFields(mockMachineCustomFieldDefs, vals)
const boolField = fields.value.find(f => f.name === 'En service')!
expect(boolField.value).toBe('false')
// Toggle to true
boolField.value = 'true'
await update(boolField)
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'true' })
})
it('boolean false is persisted in saveAll (not skipped)', async () => {
// Only the boolean field with value "false"
const boolDef = mockMachineCustomFieldDefs[1]!
const boolVal = { ...mockMachineCustomFieldValues[1]!, value: 'false' }
const { fields, saveAll } = createMachineFields([boolDef], [boolVal])
expect(fields.value[0]?.value).toBe('false')
const failed = await saveAll()
expect(failed).toEqual([])
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-002', { value: 'false' })
})
})
// ---------------------------------------------------------------------------
// Number zero — not lost
// ---------------------------------------------------------------------------
describe('number zero — not lost', () => {
it('preserves zero value after load', () => {
const { fields } = createMachineFields()
const numField = fields.value.find(f => f.name === 'Puissance (kW)')!
expect(numField.value).toBe('0')
})
it('saves zero value (not skipped) in saveAll', async () => {
// Only the number field with value "0"
const numDef = mockMachineCustomFieldDefs[2]!
const numVal = mockMachineCustomFieldValues[2]!
const { saveAll } = createMachineFields([numDef], [numVal])
const failed = await saveAll()
expect(failed).toEqual([])
expect(mockUpdateCustomFieldValue).toHaveBeenCalledWith('mcfv-003', { value: '0' })
})
})
// ---------------------------------------------------------------------------
// Select field
// ---------------------------------------------------------------------------
describe('select field', () => {
it('preserves selected option', () => {
const { fields } = createMachineFields()
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')!
expect(selectField.value).toBe('Zone 1')
})
it('uses defaultValue when no value exists', () => {
// Use defs with a select that has a defaultValue
const defsWithDefault = mockMachineCustomFieldDefs.map((d, i) =>
i === 3 ? { ...d, defaultValue: 'Non classé' } : d,
)
// No values for the select field
const valsWithoutSelect = mockMachineCustomFieldValues.filter(
v => v.customField.name !== 'Catégorie ATEX',
)
const { fields } = createMachineFields(defsWithDefault, valsWithoutSelect)
const selectField = fields.value.find(f => f.name === 'Catégorie ATEX')!
expect(selectField.value).toBe('Non classé')
})
})
// ---------------------------------------------------------------------------
// Field isolation
// ---------------------------------------------------------------------------
describe('field isolation', () => {
it('updating one field does not change other field values', async () => {
const { fields, update } = createMachineFields()
// Snapshot original values
const originalValues = fields.value.map(f => ({ name: f.name, value: f.value }))
// Update only the text field
const textField = fields.value.find(f => f.name === 'Numéro de série')!
textField.value = 'SN-UPDATED-999'
await update(textField)
// All other fields should still have their original values
for (const field of fields.value) {
if (field.name === 'Numéro de série') continue
const original = originalValues.find(o => o.name === field.name)
expect(field.value).toBe(original?.value)
}
})
it('saveAll preserves all field values even on partial failure', async () => {
// Make the second call fail (boolean field)
mockUpdateCustomFieldValue
.mockResolvedValueOnce({ success: true }) // text — Numéro de série
.mockResolvedValueOnce({ success: false }) // boolean — En service
.mockResolvedValue({ success: true }) // rest succeed
const { fields, saveAll } = createMachineFields()
// Snapshot values before saveAll
const valuesBefore = fields.value.map(f => ({ name: f.name, value: f.value }))
const failed = await saveAll()
// Only the boolean field should have failed
expect(failed).toEqual(['En service'])
// All field values should still be intact (not cleared or corrupted)
for (const field of fields.value) {
const before = valuesBefore.find(v => v.name === field.name)
expect(field.value).toBe(before?.value)
}
})
})

View File

@@ -0,0 +1,700 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
// ---------------------------------------------------------------------------
// Mock data — realistic /machines/{id}/structure response
// ---------------------------------------------------------------------------
const MACHINE_ID = 'cl-machine-abc123'
const SITE_ID = 'cl-site-nord-001'
const COMPONENT_LINK_ID = 'cl-mcl-001'
const PIECE_LINK_ID = 'cl-mpl-001'
const PRODUCT_LINK_ID = 'cl-mprl-001'
const COMPOSANT_ID = 'cl-comp-moteur-001'
const PIECE_ID = 'cl-piece-roul-001'
const PRODUCT_ID = 'cl-prod-graisse-001'
const CONSTRUCTEUR_ID = 'cstr-skf-001'
const mockConstructeurSKF = {
id: CONSTRUCTEUR_ID,
name: 'SKF',
email: 'contact@skf.com',
phone: '+33 1 23 45 67 89',
}
const mockStructureResponse = {
success: true,
data: {
machine: {
id: MACHINE_ID,
name: 'Presse hydraulique PH-200',
reference: 'MACH-PH-200',
prix: 150000,
siteId: SITE_ID,
site: { id: SITE_ID, name: 'Usine Nord' },
documents: [{ id: 'doc-001', name: 'Manuel PH-200.pdf', type: 'manual' }],
customFieldValues: [
{
id: 'mcfv-001',
value: 'SN-2025-PH200',
customField: {
id: 'mcf-001',
name: 'Serial Number',
type: 'text',
required: true,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
},
},
],
customFields: [
{
id: 'mcf-001',
name: 'Serial Number',
type: 'text',
required: true,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
},
],
constructeurs: [
{
id: 'cl-mconst-001',
constructeur: mockConstructeurSKF,
supplierReference: 'SKF-PH200',
},
],
},
componentLinks: [
{
id: COMPONENT_LINK_ID,
composant: {
id: COMPOSANT_ID,
name: 'Moteur principal',
reference: 'COMP-MOT-001',
prix: 12500,
typeComposant: { id: 'tc-moteur', name: 'Moteur electrique' },
constructeurs: [mockConstructeurSKF],
constructeurIds: [CONSTRUCTEUR_ID],
documents: [],
customFields: [
{
definitionId: 'cf-comp-001',
name: 'Tension nominale',
type: 'number',
value: '380',
},
],
customFieldValues: [],
},
overrides: {
name: 'Moteur principal PH-200',
reference: 'COMP-MOT-PH200',
prix: 13000,
},
contextCustomFields: [
{
id: 'ctx-cf-001',
name: 'Position sur machine',
type: 'text',
machineContextOnly: true,
},
],
contextCustomFieldValues: [
{
id: 'ctx-cfv-001',
value: 'Bloc moteur gauche',
customField: {
id: 'ctx-cf-001',
name: 'Position sur machine',
type: 'text',
machineContextOnly: true,
},
},
],
pieceLinks: [
{
id: PIECE_LINK_ID,
piece: {
id: PIECE_ID,
name: 'Roulement 6205',
reference: 'ROUL-6205',
prix: 45.90,
typePiece: { id: 'tp-bearing', name: 'Roulement' },
constructeurs: [mockConstructeurSKF],
documents: [],
customFields: [],
},
overrides: {
name: 'Roulement 6205-RS',
},
quantity: 2,
parentComponentLinkId: COMPONENT_LINK_ID,
contextCustomFields: [],
contextCustomFieldValues: [
{
id: 'ctx-cfv-piece-001',
value: 'Cote entrainement',
customField: {
id: 'ctx-cf-piece-001',
name: 'Emplacement',
type: 'text',
machineContextOnly: true,
},
},
],
},
],
childLinks: [],
},
],
pieceLinks: [],
productLinks: [
{
id: PRODUCT_LINK_ID,
product: {
id: PRODUCT_ID,
name: 'Graisse LGMT2',
reference: 'LUB-LGMT2',
prix: 45.90,
},
overrides: null,
},
],
},
}
// Response with NO overrides — for fallback testing
const mockStructureNoOverrides = {
success: true,
data: {
machine: {
...mockStructureResponse.data.machine,
},
componentLinks: [
{
id: COMPONENT_LINK_ID,
composant: {
id: COMPOSANT_ID,
name: 'Moteur principal',
reference: 'COMP-MOT-001',
prix: 12500,
typeComposant: { id: 'tc-moteur', name: 'Moteur electrique' },
constructeurs: [],
documents: [],
customFields: [],
customFieldValues: [],
},
overrides: null,
contextCustomFields: [],
contextCustomFieldValues: [],
pieceLinks: [
{
id: PIECE_LINK_ID,
piece: {
id: PIECE_ID,
name: 'Roulement 6205',
reference: 'ROUL-6205',
prix: 45.90,
typePiece: { id: 'tp-bearing', name: 'Roulement' },
constructeurs: [],
documents: [],
customFields: [],
},
overrides: null,
quantity: 1,
parentComponentLinkId: COMPONENT_LINK_ID,
contextCustomFields: [],
contextCustomFieldValues: [],
},
],
childLinks: [],
},
],
pieceLinks: [],
productLinks: [],
},
}
// ---------------------------------------------------------------------------
// Mocks — all composables used by useMachineDetailData
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPatch = vi.fn()
const mockPost = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
patch: mockPatch,
post: mockPost,
delete: mockDel,
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
vi.mock('~/composables/useMachines', () => ({
useMachines: () => ({
updateMachine: vi.fn().mockResolvedValue({ success: true }),
updateStructure: vi.fn().mockResolvedValue({ success: true }),
}),
}))
vi.mock('~/composables/useComposants', () => ({
useComposants: () => ({
updateComposant: vi.fn().mockResolvedValue({ success: true }),
}),
}))
vi.mock('~/composables/usePieces', () => ({
usePieces: () => ({
updatePiece: vi.fn().mockResolvedValue({ success: true }),
}),
}))
vi.mock('~/composables/useComponentTypes', () => ({
useComponentTypes: () => ({
componentTypes: ref([
{ id: 'tc-moteur', name: 'Moteur electrique' },
]),
loadComponentTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/usePieceTypes', () => ({
usePieceTypes: () => ({
pieceTypes: ref([
{ id: 'tp-bearing', name: 'Roulement' },
]),
loadPieceTypes: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useCustomFields', () => ({
useCustomFields: () => ({
upsertCustomFieldValue: vi.fn().mockResolvedValue({ success: true }),
updateCustomFieldValue: vi.fn().mockResolvedValue({ success: true }),
}),
}))
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
constructeurs: ref([mockConstructeurSKF]),
loadConstructeurs: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useSites', () => ({
useSites: () => ({
sites: ref([{ id: SITE_ID, name: 'Usine Nord' }]),
loadSites: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useProducts', () => ({
useProducts: () => ({
products: ref([
{ id: PRODUCT_ID, name: 'Graisse LGMT2', reference: 'LUB-LGMT2', prix: 45.90 },
]),
loadProducts: vi.fn().mockResolvedValue(undefined),
}),
}))
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
uploadDocuments: vi.fn().mockResolvedValue({ success: true }),
deleteDocument: vi.fn().mockResolvedValue({ success: true }),
loadDocumentsByMachine: vi.fn().mockResolvedValue({ success: true, data: [] }),
loadDocumentsByProduct: vi.fn().mockResolvedValue({ success: true, data: [] }),
}),
}))
vi.mock('~/composables/useConstructeurLinks', () => ({
useConstructeurLinks: () => ({
fetchLinks: vi.fn().mockResolvedValue([]),
syncLinks: vi.fn().mockResolvedValue({ success: true }),
}),
}))
vi.mock('~/utils/printTemplates/machineReport', () => ({
buildMachinePrintContext: vi.fn(),
buildMachinePrintHtml: vi.fn().mockReturnValue('<html></html>'),
}))
vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: vi.fn().mockReturnValue(false),
}))
vi.mock('~/shared/utils/documentDisplayUtils', () => ({
downloadDocument: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Import under test (after mocks)
// ---------------------------------------------------------------------------
import { useMachineDetailData } from '~/composables/useMachineDetailData'
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue(mockStructureResponse)
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function loadAndReturn(responseOverride?: unknown) {
if (responseOverride) {
mockGet.mockResolvedValue(responseOverride)
}
const result = useMachineDetailData(MACHINE_ID)
await result.loadMachineData()
return result
}
// ===========================================================================
// 1. Hierarchy loading
// ===========================================================================
describe('hierarchy loading', () => {
it('loads machine with all core fields', async () => {
const { machine, machineName, machineReference, machineSiteId } = await loadAndReturn()
expect(machine.value).not.toBeNull()
expect(machine.value!.id).toBe(MACHINE_ID)
expect(machine.value!.name).toBe('Presse hydraulique PH-200')
expect(machine.value!.reference).toBe('MACH-PH-200')
expect(machine.value!.prix).toBe(150000)
expect(machineName.value).toBe('Presse hydraulique PH-200')
expect(machineReference.value).toBe('MACH-PH-200')
expect(machineSiteId.value).toBe(SITE_ID)
})
it('calls GET /machines/{id}/structure', async () => {
await loadAndReturn()
expect(mockGet).toHaveBeenCalledWith(`/machines/${MACHINE_ID}/structure`)
})
it('loads componentLinks from structure response', async () => {
const { machineComponentLinks } = await loadAndReturn()
expect(machineComponentLinks.value).toHaveLength(1)
expect(machineComponentLinks.value[0]!.id).toBe(COMPONENT_LINK_ID)
})
it('builds component hierarchy with composant data', async () => {
const { components } = await loadAndReturn()
expect(components.value.length).toBeGreaterThanOrEqual(1)
const comp = components.value[0]!
expect(comp.composantId).toBe(COMPOSANT_ID)
})
it('loads piece links nested under their parent componentLink', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
const pieces = comp.pieces as Record<string, unknown>[]
expect(pieces).toBeDefined()
expect(pieces.length).toBeGreaterThanOrEqual(1)
const piece = pieces[0]!
expect(piece.pieceId).toBe(PIECE_ID)
expect(piece.parentComponentLinkId).toBe(COMPONENT_LINK_ID)
})
it('preserves piece quantity', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
const pieces = comp.pieces as Record<string, unknown>[]
const piece = pieces[0]!
expect(piece.quantity).toBe(2)
})
it('loads product links at machine level', async () => {
const { machineProductLinks } = await loadAndReturn()
expect(machineProductLinks.value).toHaveLength(1)
expect(machineProductLinks.value[0]!.id).toBe(PRODUCT_LINK_ID)
})
it('preserves machine documents', async () => {
const { machine } = await loadAndReturn()
const docs = machine.value!.documents as unknown[]
expect(docs).toHaveLength(1)
})
it('preserves machine customFieldValues', async () => {
const { machine } = await loadAndReturn()
const cfv = machine.value!.customFieldValues as Record<string, unknown>[]
expect(cfv).toHaveLength(1)
expect((cfv[0] as any).value).toBe('SN-2025-PH200')
})
it('sets loading to false after data load', async () => {
const { loading } = await loadAndReturn()
expect(loading.value).toBe(false)
})
it('handles failed API response gracefully', async () => {
const { machine, components, pieces } = await loadAndReturn({
success: false,
error: 'Not found',
})
expect(machine.value).toBeNull()
expect(components.value).toEqual([])
expect(pieces.value).toEqual([])
})
it('handles invalid machine payload gracefully', async () => {
const { machine } = await loadAndReturn({
success: true,
data: null,
})
expect(machine.value).toBeNull()
})
})
// ===========================================================================
// 2. Overrides
// ===========================================================================
describe('overrides on component links', () => {
it('uses nameOverride when present', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
expect(comp.name).toBe('Moteur principal PH-200')
})
it('falls back to composant.name when nameOverride is null', async () => {
const { components } = await loadAndReturn(mockStructureNoOverrides)
const comp = components.value[0]!
expect(comp.name).toBe('Moteur principal')
})
it('uses referenceOverride when present', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
expect(comp.reference).toBe('COMP-MOT-PH200')
})
it('falls back to composant.reference when referenceOverride is null', async () => {
const { components } = await loadAndReturn(mockStructureNoOverrides)
const comp = components.value[0]!
expect(comp.reference).toBe('COMP-MOT-001')
})
it('uses prixOverride when present', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
expect(comp.prix).toBe(13000)
})
it('falls back to composant.prix when prixOverride is null', async () => {
const { components } = await loadAndReturn(mockStructureNoOverrides)
const comp = components.value[0]!
expect(comp.prix).toBe(12500)
})
})
describe('overrides on piece links', () => {
it('uses piece nameOverride when present', async () => {
const { components } = await loadAndReturn()
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
expect(piece.name).toBe('Roulement 6205-RS')
})
it('falls back to piece.name when nameOverride is null', async () => {
const { components } = await loadAndReturn(mockStructureNoOverrides)
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
expect(piece.name).toBe('Roulement 6205')
})
it('preserves piece reference from underlying entity when no override', async () => {
const { components } = await loadAndReturn()
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
// The override only has name, so reference comes from the piece entity
expect(piece.reference).toBe('ROUL-6205')
})
it('preserves piece prix from underlying entity when no override', async () => {
const { components } = await loadAndReturn()
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
expect(piece.prix).toBe(45.90)
})
})
// ===========================================================================
// 3. Custom field values on links (context fields)
// ===========================================================================
describe('contextCustomFieldValues on component links', () => {
it('loads contextCustomFieldValues on component hierarchy nodes', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
const ctxValues = comp.contextCustomFieldValues as Record<string, unknown>[]
expect(ctxValues).toBeDefined()
expect(ctxValues).toHaveLength(1)
expect((ctxValues[0] as any).value).toBe('Bloc moteur gauche')
expect((ctxValues[0] as any).customField.name).toBe('Position sur machine')
})
it('loads contextCustomFields definitions on component hierarchy nodes', async () => {
const { components } = await loadAndReturn()
const comp = components.value[0]!
const ctxFields = comp.contextCustomFields as Record<string, unknown>[]
expect(ctxFields).toBeDefined()
expect(ctxFields).toHaveLength(1)
expect((ctxFields[0] as any).name).toBe('Position sur machine')
expect((ctxFields[0] as any).machineContextOnly).toBe(true)
})
})
describe('contextCustomFieldValues on piece links', () => {
it('loads contextCustomFieldValues on piece hierarchy nodes', async () => {
const { components } = await loadAndReturn()
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
const ctxValues = piece.contextCustomFieldValues as Record<string, unknown>[]
expect(ctxValues).toBeDefined()
expect(ctxValues).toHaveLength(1)
expect((ctxValues[0] as any).value).toBe('Cote entrainement')
})
it('has empty contextCustomFieldValues when none provided', async () => {
const { components } = await loadAndReturn(mockStructureNoOverrides)
const piece = (components.value[0]!.pieces as Record<string, unknown>[])[0]!
const ctxValues = piece.contextCustomFieldValues as Record<string, unknown>[]
expect(ctxValues).toEqual([])
})
})
// ===========================================================================
// 4. Constructeur links on machine
// ===========================================================================
describe('constructeur links on machine', () => {
it('parses constructeur links from machine data', async () => {
const { constructeurLinks } = await loadAndReturn()
expect(constructeurLinks.value).toHaveLength(1)
expect(constructeurLinks.value[0]!.constructeurId).toBe(CONSTRUCTEUR_ID)
expect(constructeurLinks.value[0]!.supplierReference).toBe('SKF-PH200')
})
it('populates machineConstructeurIds from links', async () => {
const { machineConstructeurIds } = await loadAndReturn()
expect(machineConstructeurIds.value).toContain(CONSTRUCTEUR_ID)
})
it('stores original constructeur links for cancel rollback', async () => {
const { originalConstructeurLinks } = await loadAndReturn()
expect(originalConstructeurLinks.value).toHaveLength(1)
expect(originalConstructeurLinks.value[0]!.constructeurId).toBe(CONSTRUCTEUR_ID)
})
it('hasMachineConstructeur is true when constructeur present', async () => {
const { hasMachineConstructeur } = await loadAndReturn()
expect(hasMachineConstructeur.value).toBe(true)
})
it('resolves constructeur display objects', async () => {
const { machineConstructeursDisplay } = await loadAndReturn()
expect(machineConstructeursDisplay.value.length).toBeGreaterThanOrEqual(1)
const display = machineConstructeursDisplay.value[0] as any
expect(display.name).toBe('SKF')
})
})
// ===========================================================================
// 5. Site (required)
// ===========================================================================
describe('site loaded with machine data', () => {
it('machineSiteId is populated from machine payload', async () => {
const { machineSiteId } = await loadAndReturn()
expect(machineSiteId.value).toBe(SITE_ID)
})
it('sites ref is available for dropdowns', async () => {
const { sites } = await loadAndReturn()
expect(sites.value).toHaveLength(1)
expect((sites.value[0] as any).name).toBe('Usine Nord')
})
it('machine.site object is preserved in machine ref', async () => {
const { machine } = await loadAndReturn()
const site = machine.value!.site as Record<string, unknown>
expect(site.id).toBe(SITE_ID)
expect(site.name).toBe('Usine Nord')
})
})
// ===========================================================================
// 6. UI state defaults
// ===========================================================================
describe('UI state defaults', () => {
it('isEditMode starts as false', () => {
const { isEditMode } = useMachineDetailData(MACHINE_ID)
expect(isEditMode.value).toBe(false)
})
it('saving starts as false', () => {
const { saving } = useMachineDetailData(MACHINE_ID)
expect(saving.value).toBe(false)
})
it('loading starts as true', () => {
const { loading } = useMachineDetailData(MACHINE_ID)
expect(loading.value).toBe(true)
})
})

View File

@@ -0,0 +1,644 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
mockPieceFromApi,
mockLinkSKF,
mockLinkFAG,
mockConstructeurSKF,
mockConstructeurFAG,
wrapCollection,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
const mockPostFormData = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: mockPostFormData,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — Toast
// ---------------------------------------------------------------------------
const mockShowSuccess = vi.fn()
const mockShowError = vi.fn()
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: mockShowSuccess,
showError: mockShowError,
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePieces (updatePiece)
// ---------------------------------------------------------------------------
const mockUpdatePiece = vi.fn()
vi.mock('~/composables/usePieces', () => ({
usePieces: () => ({
updatePiece: mockUpdatePiece,
pieces: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePieceTypes
// ---------------------------------------------------------------------------
const mockPieceTypes = { value: [] as any[] }
const mockLoadPieceTypes = vi.fn().mockResolvedValue(undefined)
vi.mock('~/composables/usePieceTypes', () => ({
usePieceTypes: () => ({
pieceTypes: mockPieceTypes,
loadPieceTypes: mockLoadPieceTypes,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useDocuments
// ---------------------------------------------------------------------------
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
loadDocumentsByPiece: vi.fn().mockResolvedValue({ success: true, data: [] }),
uploadDocuments: vi.fn().mockResolvedValue({ success: true, data: [] }),
deleteDocument: vi.fn().mockResolvedValue({ success: true }),
documents: { value: [] },
loading: { value: false },
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurLinks
// ---------------------------------------------------------------------------
const mockFetchLinks = vi.fn().mockResolvedValue([])
const mockSyncLinks = vi.fn().mockResolvedValue(undefined)
vi.mock('~/composables/useConstructeurLinks', () => ({
useConstructeurLinks: () => ({
fetchLinks: mockFetchLinks,
syncLinks: mockSyncLinks,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useCustomFieldInputs
// ---------------------------------------------------------------------------
const mockSaveAll = vi.fn().mockResolvedValue([])
const mockRefreshCF = vi.fn()
vi.mock('~/composables/useCustomFieldInputs', () => ({
useCustomFieldInputs: () => ({
fields: { value: [] },
requiredFilled: { value: true },
saveAll: mockSaveAll,
refresh: mockRefreshCF,
}),
}))
// ---------------------------------------------------------------------------
// Mocks — usePermissions (auto-imported in Nuxt)
// ---------------------------------------------------------------------------
vi.stubGlobal('usePermissions', () => ({
canEdit: { value: true },
canManage: { value: true },
isAdmin: { value: false },
isGranted: () => true,
}))
// ---------------------------------------------------------------------------
// Mocks — useConstructeurs
// ---------------------------------------------------------------------------
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — useEntityHistory
// ---------------------------------------------------------------------------
vi.mock('~/composables/useEntityHistory', () => ({
useEntityHistory: () => ({
history: { value: [] },
loading: { value: false },
error: { value: null },
loadHistory: vi.fn().mockResolvedValue([]),
}),
}))
// ---------------------------------------------------------------------------
// Mocks — shared utils
// ---------------------------------------------------------------------------
vi.mock('~/shared/modelUtils', () => ({
formatPieceStructurePreview: () => '',
}))
vi.mock('~/shared/constructeurUtils', () => ({
uniqueConstructeurIds: (ids: string[]) => [...new Set(ids)],
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
}))
vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: () => false,
}))
vi.mock('~/services/modelTypes', () => ({
getModelType: vi.fn().mockResolvedValue(null),
}))
vi.mock('~/shared/apiRelations', () => ({
extractRelationId: (rel: any) => {
if (typeof rel === 'string') return rel
if (rel && typeof rel === 'object' && 'id' in rel) return rel.id
return null
},
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { usePieceEdit } from '~/composables/usePieceEdit'
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const PIECE_ID = 'piece-001'
const mockPieceType = {
id: 'tp-bearing-001',
name: 'Roulement',
code: 'ROUL',
category: 'PIECE',
structure: {
products: [
{
typeProductId: 'tprod-grease-001',
typeProductLabel: 'Graisse SKF',
familyCode: 'LUB',
role: 'lubrification',
},
],
customFields: [],
},
}
function buildPieceWithProducts() {
return {
...mockPieceFromApi,
id: PIECE_ID,
'@id': `/api/pieces/${PIECE_ID}`,
description: 'Roulement haute performance',
prix: '42.50',
typePieceId: 'tp-bearing-001',
productIds: ['prod-001'],
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const tick = () => new Promise(r => setTimeout(r, 0))
async function createAndHydrate(overrides?: Record<string, any>) {
const pieceData = { ...buildPieceWithProducts(), ...overrides }
mockGet.mockImplementation((url: string) => {
if (url.includes(`/pieces/${PIECE_ID}`)) {
return Promise.resolve({ success: true, data: structuredClone(pieceData) })
}
return Promise.resolve({ success: true, data: wrapCollection([]) })
})
mockFetchLinks.mockResolvedValue([
{ ...mockLinkSKF },
{ ...mockLinkFAG },
])
const composable = usePieceEdit(PIECE_ID)
await composable.fetchPiece()
await tick()
return composable
}
// ---------------------------------------------------------------------------
// beforeEach
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks()
mockPieceTypes.value = [mockPieceType]
})
// ---------------------------------------------------------------------------
// fetchPiece — hydration
// ---------------------------------------------------------------------------
describe('fetchPiece — hydration', () => {
it('loads all simple fields (name, reference, description, prix)', async () => {
const composable = await createAndHydrate()
expect(composable.editionForm.name).toBe('Roulement 6205')
expect(composable.editionForm.reference).toBe('ROUL-6205')
expect(composable.editionForm.description).toBe('Roulement haute performance')
expect(composable.editionForm.prix).toBe('42.50')
})
it('loads piece with product slots', async () => {
const composable = await createAndHydrate()
expect(composable.piece.value).not.toBeNull()
expect(composable.piece.value.productSlots).toHaveLength(1)
expect(composable.piece.value.productSlots[0].product.id).toBe('prod-001')
})
it('loads constructeur links via fetchLinks', async () => {
const composable = await createAndHydrate()
expect(mockFetchLinks).toHaveBeenCalledWith('piece', PIECE_ID)
expect(composable.constructeurLinks.value).toHaveLength(2)
expect(composable.constructeurLinks.value[0].constructeurId).toBe(mockConstructeurSKF.id)
expect(composable.constructeurLinks.value[1].constructeurId).toBe(mockConstructeurFAG.id)
})
})
// ---------------------------------------------------------------------------
// Product selections
// ---------------------------------------------------------------------------
describe('product selections', () => {
it('setProductSelection updates the correct index', async () => {
const composable = await createAndHydrate()
// The structure has 1 product requirement, so productSelections should have 1 entry
composable.setProductSelection(0, 'prod-new-001')
await tick()
expect(composable.productSelections.value[0]).toBe('prod-new-001')
})
it('setProductSelection to null does not crash', async () => {
const composable = await createAndHydrate()
// Set then clear
composable.setProductSelection(0, 'prod-001')
await tick()
composable.setProductSelection(0, null)
await tick()
expect(composable.productSelections.value[0]).toBeNull()
})
})
// ---------------------------------------------------------------------------
// submitEdition — no data loss
// ---------------------------------------------------------------------------
describe('submitEdition — no data loss', () => {
it('sends all form fields in update payload', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.editionForm.name = 'Roulement modifie'
composable.editionForm.description = 'Nouvelle description'
composable.editionForm.reference = 'REF-MOD-001'
composable.editionForm.prix = '99.99'
// Ensure product selection is filled so submit proceeds
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload).toMatchObject({
name: 'Roulement modifie',
description: 'Nouvelle description',
reference: 'REF-MOD-001',
prix: '99.99',
})
})
it('saves custom fields after piece update', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
expect(mockSaveAll).toHaveBeenCalledTimes(1)
})
it('syncs constructeur links', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
const [entityType, entityId, origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
expect(entityType).toBe('piece')
expect(entityId).toBe(PIECE_ID)
expect(origLinks).toHaveLength(2)
expect(formLinks).toHaveLength(2)
})
it('editing name does not lose constructeur links', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
// Only edit name
composable.editionForm.name = 'Nouveau nom piece'
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.name).toBe('Nouveau nom piece')
// syncLinks still called with constructeur links preserved
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
expect(origLinks).toHaveLength(2)
expect(formLinks).toHaveLength(2)
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id)
})
it('editing name does not lose product slots', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
// Set product selection
composable.setProductSelection(0, 'prod-001')
await tick()
// Now edit only name
composable.editionForm.name = 'Autre nom'
await tick()
await composable.submitEdition()
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.name).toBe('Autre nom')
// productIds should still contain the selection
expect(payload.productIds).toContain('prod-001')
})
it('adding a constructeur preserves existing ones', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
// Initially has SKF + FAG from fetchLinks
expect(composable.constructeurLinks.value).toHaveLength(2)
// Add a third constructeur
const newLink = {
linkId: null as string | null,
constructeurId: 'cstr-new-003',
constructeur: { id: 'cstr-new-003', name: 'NEW Corp', email: null, phone: null },
supplierReference: 'NEW-REF-001',
}
composable.constructeurLinks.value = [
...composable.constructeurLinks.value,
newLink,
]
await tick()
await composable.submitEdition()
expect(mockSyncLinks).toHaveBeenCalledTimes(1)
const [, , origLinks, formLinks] = mockSyncLinks.mock.calls[0]!
// Original had 2 (SKF + FAG)
expect(origLinks).toHaveLength(2)
// Form now has 3 (SKF + FAG + NEW)
expect(formLinks).toHaveLength(3)
expect(formLinks[0].constructeurId).toBe(mockConstructeurSKF.id)
expect(formLinks[1].constructeurId).toBe(mockConstructeurFAG.id)
expect(formLinks[2].constructeurId).toBe('cstr-new-003')
})
it('sends both productId and productIds in payload', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.productId).toBe('prod-001')
expect(payload.productIds).toEqual(['prod-001'])
})
it('productId is the first product selection when multiple exist', async () => {
// Override the piece type to have 2 product requirements
const multiProductType = {
...mockPieceType,
structure: {
...mockPieceType.structure,
products: [
{
typeProductId: 'tprod-grease-001',
typeProductLabel: 'Graisse SKF',
familyCode: 'LUB',
role: 'lubrification',
},
{
typeProductId: 'tprod-oil-002',
typeProductLabel: 'Huile',
familyCode: 'LUB',
role: 'lubrification secondaire',
},
],
customFields: [],
},
}
mockPieceTypes.value = [multiProductType]
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate({
productIds: ['prod-001', 'prod-002'],
})
composable.setProductSelection(0, 'prod-001')
composable.setProductSelection(1, 'prod-002')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.productId).toBe('prod-001')
expect(payload.productIds).toEqual(['prod-001', 'prod-002'])
})
})
// ---------------------------------------------------------------------------
// submitEdition — null field handling
// ---------------------------------------------------------------------------
describe('submitEdition — null field handling', () => {
it('empty prix sends null', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = ''
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.prix).toBeNull()
})
it('whitespace-only prix sends null', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = ' '
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.prix).toBeNull()
})
it('empty reference sends null', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.editionForm.reference = ''
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.reference).toBeNull()
})
it('valid prix is sent as string number', async () => {
mockUpdatePiece.mockResolvedValue({ success: true, data: { id: PIECE_ID } })
const composable = await createAndHydrate()
composable.editionForm.prix = '99.50'
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
const payload = mockUpdatePiece.mock.calls[0]![1]
expect(payload.prix).toBe('99.5')
})
})
// ---------------------------------------------------------------------------
// submitEdition — error paths
// ---------------------------------------------------------------------------
describe('submitEdition — error paths', () => {
it('does not save custom fields when updatePiece fails', async () => {
mockUpdatePiece.mockResolvedValue({ success: false, error: 'Server error' })
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
expect(mockSaveAll).not.toHaveBeenCalled()
expect(mockSyncLinks).not.toHaveBeenCalled()
})
it('does not save custom fields when updatePiece throws', async () => {
mockUpdatePiece.mockRejectedValue(new Error('Network failure'))
const composable = await createAndHydrate()
composable.setProductSelection(0, 'prod-001')
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).toHaveBeenCalledTimes(1)
expect(mockSaveAll).not.toHaveBeenCalled()
expect(mockSyncLinks).not.toHaveBeenCalled()
expect(mockShowError).toHaveBeenCalledWith('Network failure')
})
it('shows error toast when product selection is not filled', async () => {
const composable = await createAndHydrate()
// Clear product selection
composable.setProductSelection(0, null)
await tick()
await composable.submitEdition()
expect(mockUpdatePiece).not.toHaveBeenCalled()
expect(mockShowError).toHaveBeenCalledWith('Sélectionnez un produit conforme au squelette.')
})
})

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { usePieces } from '~/composables/usePieces'
import { mockPieceFromApi, wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
const { clearPiecesCache } = usePieces()
clearPiecesCache()
})
// ---------------------------------------------------------------------------
// createPiece
// ---------------------------------------------------------------------------
describe('createPiece', () => {
it('sends all fields including prix in POST payload', async () => {
const created = { ...mockPieceFromApi, id: 'piece-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createPiece } = usePieces()
await createPiece({
name: 'Roulement 6205',
reference: 'ROUL-6205',
prix: 25.50,
typePieceId: 'tp-bearing-001',
} as any)
expect(mockPost).toHaveBeenCalledWith('/pieces', expect.objectContaining({
name: 'Roulement 6205',
reference: 'ROUL-6205',
prix: 25.50,
typePiece: '/api/model_types/tp-bearing-001',
}))
})
it('strips constructeur fields from payload', async () => {
const created = { ...mockPieceFromApi, id: 'piece-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createPiece } = usePieces()
await createPiece({
name: 'Test Piece',
constructeurIds: ['cstr-skf-001'],
constructeurs: [{ id: 'cstr-skf-001', name: 'SKF' }] as any,
})
const payload = mockPost.mock.calls[0]![1]
expect(payload).not.toHaveProperty('constructeurIds')
expect(payload).not.toHaveProperty('constructeurs')
expect(payload).not.toHaveProperty('constructeurId')
expect(payload).not.toHaveProperty('constructeur')
})
it('adds created piece to cache (pieces array and total)', async () => {
const created = { ...mockPieceFromApi, id: 'piece-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createPiece, pieces, total } = usePieces()
const result = await createPiece({ name: 'New Piece' })
expect(result.success).toBe(true)
expect(pieces.value).toHaveLength(1)
expect(pieces.value[0]!.id).toBe('piece-new')
expect(total.value).toBe(1)
})
})
// ---------------------------------------------------------------------------
// updatePiece
// ---------------------------------------------------------------------------
describe('updatePiece', () => {
it('patches with supplied fields and updates cache', async () => {
// Seed cache first
const original = { ...mockPieceFromApi }
mockPost.mockResolvedValue({ success: true, data: original })
const { createPiece, updatePiece, pieces } = usePieces()
await createPiece({ name: 'Roulement 6205' })
const updated = { ...mockPieceFromApi, name: 'Updated Name', reference: 'ROUL-NEW' }
mockPatch.mockResolvedValue({ success: true, data: updated })
const result = await updatePiece(mockPieceFromApi.id, {
name: 'Updated Name',
reference: 'ROUL-NEW',
})
expect(mockPatch).toHaveBeenCalledWith(`/pieces/${mockPieceFromApi.id}`, expect.objectContaining({
name: 'Updated Name',
reference: 'ROUL-NEW',
}))
expect(result.success).toBe(true)
expect(pieces.value.find(p => p.id === mockPieceFromApi.id)?.name).toBe('Updated Name')
})
})
// ---------------------------------------------------------------------------
// deletePiece
// ---------------------------------------------------------------------------
describe('deletePiece', () => {
it('removes piece from cache on success', async () => {
// Seed cache
mockPost.mockResolvedValue({ success: true, data: { ...mockPieceFromApi } })
const { createPiece, deletePiece, pieces, total } = usePieces()
await createPiece({ name: 'To Delete' })
expect(pieces.value).toHaveLength(1)
mockDel.mockResolvedValue({ success: true })
const result = await deletePiece(mockPieceFromApi.id)
expect(result.success).toBe(true)
expect(pieces.value).toHaveLength(0)
expect(total.value).toBe(0)
})
it('does not remove on failure', async () => {
// Seed cache
mockPost.mockResolvedValue({ success: true, data: { ...mockPieceFromApi } })
const { createPiece, deletePiece, pieces, total } = usePieces()
await createPiece({ name: 'Should Stay' })
expect(pieces.value).toHaveLength(1)
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
const result = await deletePiece(mockPieceFromApi.id)
expect(result.success).toBe(false)
expect(pieces.value).toHaveLength(1)
expect(total.value).toBe(1)
})
})

View File

@@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useProducts } from '~/composables/useProducts'
import { mockProductFromApi, mockConstructeurSKF, wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDel = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
put: vi.fn(),
delete: mockDel,
postFormData: vi.fn(),
}),
}))
vi.mock('~/composables/useToast', () => ({
useToast: () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
showInfo: vi.fn(),
showToast: vi.fn(),
toasts: { value: [] },
clearAll: vi.fn(),
}),
}))
vi.mock('~/composables/useConstructeurs', () => ({
useConstructeurs: () => ({
ensureConstructeurs: vi.fn().mockResolvedValue([]),
}),
}))
beforeEach(() => {
vi.clearAllMocks()
const { clearProductsCache } = useProducts()
clearProductsCache()
})
// ---------------------------------------------------------------------------
// createProduct
// ---------------------------------------------------------------------------
describe('createProduct', () => {
it('sends all fields including supplierPrice in POST payload', async () => {
const created = { ...mockProductFromApi, id: 'prod-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createProduct } = useProducts()
await createProduct({
name: 'Graisse LGMT2',
reference: 'LUB-LGMT2',
supplierPrice: 45.90,
typeProductId: 'tprod-grease-001',
})
expect(mockPost).toHaveBeenCalledWith('/products', expect.objectContaining({
name: 'Graisse LGMT2',
reference: 'LUB-LGMT2',
supplierPrice: 45.90,
typeProduct: '/api/model_types/tprod-grease-001',
}))
})
it('strips constructeur fields from payload', async () => {
const created = { ...mockProductFromApi, id: 'prod-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createProduct } = useProducts()
await createProduct({
name: 'Test Product',
constructeurIds: ['cstr-skf-001'],
constructeurs: [mockConstructeurSKF] as any,
})
const payload = mockPost.mock.calls[0]![1]
expect(payload).not.toHaveProperty('constructeurIds')
expect(payload).not.toHaveProperty('constructeurs')
expect(payload).not.toHaveProperty('constructeurId')
expect(payload).not.toHaveProperty('constructeur')
})
it('adds created product to cache (products array and total)', async () => {
const created = { ...mockProductFromApi, id: 'prod-new' }
mockPost.mockResolvedValue({ success: true, data: created })
const { createProduct, products, total } = useProducts()
const result = await createProduct({ name: 'New Product' })
expect(result.success).toBe(true)
expect(products.value).toHaveLength(1)
expect(products.value[0]!.id).toBe('prod-new')
expect(total.value).toBe(1)
})
})
// ---------------------------------------------------------------------------
// updateProduct
// ---------------------------------------------------------------------------
describe('updateProduct', () => {
it('patches with supplied fields and updates cache', async () => {
// Seed cache first
const original = { ...mockProductFromApi }
mockPost.mockResolvedValue({ success: true, data: original })
const { createProduct, updateProduct, products } = useProducts()
await createProduct({ name: 'Graisse LGMT2' })
const updated = { ...mockProductFromApi, name: 'Updated Name', supplierPrice: 99.99 }
mockPatch.mockResolvedValue({ success: true, data: updated })
const result = await updateProduct(mockProductFromApi.id, {
name: 'Updated Name',
supplierPrice: 99.99,
})
expect(mockPatch).toHaveBeenCalledWith(`/products/${mockProductFromApi.id}`, expect.objectContaining({
name: 'Updated Name',
supplierPrice: 99.99,
}))
expect(result.success).toBe(true)
expect(products.value.find(p => p.id === mockProductFromApi.id)?.name).toBe('Updated Name')
})
})
// ---------------------------------------------------------------------------
// deleteProduct
// ---------------------------------------------------------------------------
describe('deleteProduct', () => {
it('removes product from cache on success', async () => {
// Seed cache
mockPost.mockResolvedValue({ success: true, data: { ...mockProductFromApi } })
const { createProduct, deleteProduct, products, total } = useProducts()
await createProduct({ name: 'To Delete' })
expect(products.value).toHaveLength(1)
mockDel.mockResolvedValue({ success: true })
const result = await deleteProduct(mockProductFromApi.id)
expect(result.success).toBe(true)
expect(products.value).toHaveLength(0)
expect(total.value).toBe(0)
})
it('does not remove on failure', async () => {
// Seed cache
mockPost.mockResolvedValue({ success: true, data: { ...mockProductFromApi } })
const { createProduct, deleteProduct, products, total } = useProducts()
await createProduct({ name: 'Should Stay' })
expect(products.value).toHaveLength(1)
mockDel.mockResolvedValue({ success: false, error: 'Server error' })
const result = await deleteProduct(mockProductFromApi.id)
expect(result.success).toBe(false)
expect(products.value).toHaveLength(1)
expect(total.value).toBe(1)
})
})
// ---------------------------------------------------------------------------
// getProduct
// ---------------------------------------------------------------------------
describe('getProduct', () => {
it('returns cached product if available with constructeurs (no extra API call)', async () => {
// Seed cache with a product that has resolved constructeurs
const productWithConstructeurs = {
...mockProductFromApi,
constructeurs: [mockConstructeurSKF],
}
mockPost.mockResolvedValue({ success: true, data: productWithConstructeurs })
const { createProduct, getProduct } = useProducts()
await createProduct({ name: 'Cached' })
mockGet.mockClear()
const result = await getProduct(mockProductFromApi.id)
expect(result.success).toBe(true)
expect(result.data?.id).toBe(mockProductFromApi.id)
expect(mockGet).not.toHaveBeenCalled()
})
it('fetches from API with force: true', async () => {
// Seed cache with a product that has resolved constructeurs
const productWithConstructeurs = {
...mockProductFromApi,
constructeurs: [mockConstructeurSKF],
}
mockPost.mockResolvedValue({ success: true, data: productWithConstructeurs })
const { createProduct, getProduct } = useProducts()
await createProduct({ name: 'Cached' })
const freshData = { ...mockProductFromApi, name: 'Fresh from API' }
mockGet.mockResolvedValue({ success: true, data: freshData })
const result = await getProduct(mockProductFromApi.id, { force: true })
expect(mockGet).toHaveBeenCalledWith(`/products/${mockProductFromApi.id}`)
expect(result.success).toBe(true)
expect(result.data?.name).toBe('Fresh from API')
})
})

438
frontend/tests/fixtures/mockData.ts vendored Normal file
View File

@@ -0,0 +1,438 @@
// ---------------------------------------------------------------------------
// Shared mock data for Inventory frontend test suite
// ---------------------------------------------------------------------------
import type { ConstructeurLinkEntry, ConstructeurSummary } from '~/shared/constructeurUtils'
import type { CustomFieldDefinition, CustomFieldValue } from '~/shared/utils/customFields'
import type { ComponentModelStructure } from '~/shared/types/inventory'
// ---------------------------------------------------------------------------
// Constructeurs
// ---------------------------------------------------------------------------
export const mockConstructeurSKF: ConstructeurSummary = {
id: 'cstr-skf-001',
name: 'SKF',
email: 'contact@skf.com',
phone: '+33 1 23 45 67 89',
}
export const mockConstructeurFAG: ConstructeurSummary = {
id: 'cstr-fag-002',
name: 'FAG',
email: 'info@fag.de',
phone: '+49 9721 91 0',
}
// ---------------------------------------------------------------------------
// Constructeur link entries
// ---------------------------------------------------------------------------
export const mockLinkSKF: ConstructeurLinkEntry = {
linkId: 'link-skf-001',
constructeurId: mockConstructeurSKF.id,
constructeur: mockConstructeurSKF,
supplierReference: 'SKF-6205-2RS',
}
export const mockLinkFAG: ConstructeurLinkEntry = {
linkId: 'link-fag-002',
constructeurId: mockConstructeurFAG.id,
constructeur: mockConstructeurFAG,
supplierReference: 'FAG-6205-C-2HRS',
}
// ---------------------------------------------------------------------------
// Custom field definitions (6 types)
// ---------------------------------------------------------------------------
export const mockCustomFieldDefs: CustomFieldDefinition[] = [
{
id: 'cf-def-001',
name: 'Tension nominale',
type: 'number',
required: true,
options: [],
defaultValue: '220',
orderIndex: 0,
machineContextOnly: false,
},
{
id: 'cf-def-002',
name: 'Certifié CE',
type: 'boolean',
required: false,
options: [],
defaultValue: 'false',
orderIndex: 1,
machineContextOnly: false,
},
{
id: 'cf-def-003',
name: 'Indice de protection',
type: 'select',
required: false,
options: ['IP54', 'IP55', 'IP65'],
defaultValue: null,
orderIndex: 2,
machineContextOnly: false,
},
{
id: 'cf-def-004',
name: 'Date de calibration',
type: 'date',
required: false,
options: [],
defaultValue: null,
orderIndex: 3,
machineContextOnly: false,
},
{
id: 'cf-def-005',
name: 'Remarques techniques',
type: 'text',
required: false,
options: [],
defaultValue: null,
orderIndex: 4,
machineContextOnly: false,
},
{
id: 'cf-def-006',
name: 'Position sur machine',
type: 'text',
required: false,
options: [],
defaultValue: null,
orderIndex: 5,
machineContextOnly: true,
},
]
// ---------------------------------------------------------------------------
// Custom field values (matching first 5 defs)
// ---------------------------------------------------------------------------
export const mockCustomFieldValues: CustomFieldValue[] = [
{
id: 'cfv-001',
value: '220',
customField: mockCustomFieldDefs[0]!,
},
{
id: 'cfv-002',
value: 'true',
customField: mockCustomFieldDefs[1]!,
},
{
id: 'cfv-003',
value: 'IP65',
customField: mockCustomFieldDefs[2]!,
},
{
id: 'cfv-004',
value: '2025-06-15',
customField: mockCustomFieldDefs[3]!,
},
{
id: 'cfv-005',
value: 'Roulement renforcé pour environnement humide',
customField: mockCustomFieldDefs[4]!,
},
]
// ---------------------------------------------------------------------------
// Component ModelType structure
// ---------------------------------------------------------------------------
export const mockComponentStructure: ComponentModelStructure = {
customFields: [
{ name: 'Tension nominale', type: 'number', required: true, defaultValue: '220', orderIndex: 0 },
{ name: 'Certifié CE', type: 'boolean', required: false, defaultValue: 'false', orderIndex: 1 },
{ name: 'Indice de protection', type: 'select', required: false, options: ['IP54', 'IP55', 'IP65'], orderIndex: 2 },
],
pieces: [
{
typePieceId: 'tp-bearing-001',
typePieceLabel: 'Roulement',
reference: 'REF-PIECE-001',
familyCode: 'ROUL',
role: 'support',
quantity: 2,
},
{
typePieceId: 'tp-seal-002',
typePieceLabel: 'Joint',
reference: 'REF-PIECE-002',
familyCode: 'JOINT',
role: 'étanchéité',
quantity: 1,
},
],
products: [
{
typeProductId: 'tprod-grease-001',
typeProductLabel: 'Graisse SKF',
reference: 'REF-PROD-001',
familyCode: 'LUB',
role: 'lubrification',
},
],
subcomponents: [
{
typeComposantId: 'tc-sub-001',
typeComposantLabel: 'Sous-ensemble palier',
familyCode: 'PAL',
alias: 'Palier avant',
subcomponents: [],
},
],
}
// ---------------------------------------------------------------------------
// Full API response — Composant
// ---------------------------------------------------------------------------
export const mockComponentFromApi = {
'@id': '/api/composants/comp-001',
'@type': 'Composant',
id: 'comp-001',
name: 'Moteur principal',
reference: 'COMP-MOT-001',
typeComposant: { id: 'tc-moteur', name: 'Moteur électrique', code: 'MOT' },
site: { id: 'site-001', name: 'Usine Nord' },
pieceSlots: [
{
id: 'ps-001',
piece: { id: 'piece-001', name: 'Roulement 6205', reference: 'ROUL-6205' },
typePiece: { id: 'tp-bearing-001', name: 'Roulement' },
role: 'support',
quantity: 2,
},
{
id: 'ps-002',
piece: { id: 'piece-002', name: 'Joint torique', reference: 'JOINT-001' },
typePiece: { id: 'tp-seal-002', name: 'Joint' },
role: 'étanchéité',
quantity: 1,
},
],
productSlots: [
{
id: 'prs-001',
product: { id: 'prod-001', name: 'Graisse LGMT2', reference: 'LUB-LGMT2' },
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF' },
role: 'lubrification',
},
],
subcomponentSlots: [
{
id: 'scs-001',
subcomponent: { id: 'comp-sub-001', name: 'Palier avant', reference: 'PAL-AV-001' },
typeComposant: { id: 'tc-sub-001', name: 'Sous-ensemble palier' },
alias: 'Palier avant',
},
],
constructeurLinks: [
{
id: mockLinkSKF.linkId,
constructeur: mockConstructeurSKF,
supplierReference: mockLinkSKF.supplierReference,
},
],
customFieldValues: mockCustomFieldValues.map(cfv => ({
id: cfv.id,
value: cfv.value,
customField: {
id: cfv.customField.id,
name: cfv.customField.name,
type: cfv.customField.type,
required: cfv.customField.required,
options: cfv.customField.options,
defaultValue: cfv.customField.defaultValue,
orderIndex: cfv.customField.orderIndex,
machineContextOnly: cfv.customField.machineContextOnly,
},
})),
createdAt: '2025-01-15T10:00:00+00:00',
updatedAt: '2025-03-20T14:30:00+00:00',
}
// ---------------------------------------------------------------------------
// Full API response — Piece
// ---------------------------------------------------------------------------
export const mockPieceFromApi = {
'@id': '/api/pieces/piece-001',
'@type': 'Piece',
id: 'piece-001',
name: 'Roulement 6205',
reference: 'ROUL-6205',
typePiece: { id: 'tp-bearing-001', name: 'Roulement', code: 'ROUL' },
site: { id: 'site-001', name: 'Usine Nord' },
productSlots: [
{
id: 'pps-001',
product: { id: 'prod-001', name: 'Graisse LGMT2', reference: 'LUB-LGMT2' },
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF' },
role: 'lubrification',
},
],
constructeurLinks: [
{
id: mockLinkSKF.linkId,
constructeur: mockConstructeurSKF,
supplierReference: mockLinkSKF.supplierReference,
},
{
id: mockLinkFAG.linkId,
constructeur: mockConstructeurFAG,
supplierReference: mockLinkFAG.supplierReference,
},
],
customFieldValues: [
{
id: 'cfv-piece-001',
value: '6205',
customField: {
id: 'cf-piece-def-001',
name: 'Référence interne',
type: 'text',
required: true,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
},
},
],
createdAt: '2025-01-10T08:00:00+00:00',
updatedAt: '2025-03-18T11:00:00+00:00',
}
// ---------------------------------------------------------------------------
// Full API response — Product
// ---------------------------------------------------------------------------
export const mockProductFromApi = {
'@id': '/api/products/prod-001',
'@type': 'Product',
id: 'prod-001',
name: 'Graisse LGMT2',
reference: 'LUB-LGMT2',
typeProduct: { id: 'tprod-grease-001', name: 'Graisse SKF', code: 'LUB' },
site: { id: 'site-001', name: 'Usine Nord' },
supplierPrice: 45.90,
constructeurLinks: [
{
id: mockLinkSKF.linkId,
constructeur: mockConstructeurSKF,
supplierReference: 'LGMT2/1',
},
],
createdAt: '2025-02-01T09:00:00+00:00',
updatedAt: '2025-03-10T16:00:00+00:00',
}
// ---------------------------------------------------------------------------
// JSON-LD collection wrapper
// ---------------------------------------------------------------------------
export function wrapCollection<T>(items: T[], total?: number) {
return {
'@context': '/api/contexts/Collection',
'@id': '/api/collection',
'@type': 'Collection',
'totalItems': total ?? items.length,
'member': items,
}
}
// ---------------------------------------------------------------------------
// Machine custom field definitions (5 types)
// ---------------------------------------------------------------------------
export const mockMachineCustomFieldDefs: CustomFieldDefinition[] = [
{
id: 'mcf-def-001',
name: 'Numéro de série',
type: 'text',
required: true,
options: [],
defaultValue: null,
orderIndex: 0,
machineContextOnly: false,
},
{
id: 'mcf-def-002',
name: 'En service',
type: 'boolean',
required: false,
options: [],
defaultValue: 'false',
orderIndex: 1,
machineContextOnly: false,
},
{
id: 'mcf-def-003',
name: 'Puissance (kW)',
type: 'number',
required: false,
options: [],
defaultValue: null,
orderIndex: 2,
machineContextOnly: false,
},
{
id: 'mcf-def-004',
name: 'Catégorie ATEX',
type: 'select',
required: false,
options: ['Zone 0', 'Zone 1', 'Zone 2', 'Non classé'],
defaultValue: null,
orderIndex: 3,
machineContextOnly: false,
},
{
id: 'mcf-def-005',
name: 'Date mise en service',
type: 'date',
required: false,
options: [],
defaultValue: null,
orderIndex: 4,
machineContextOnly: false,
},
]
// ---------------------------------------------------------------------------
// Machine custom field values (matching defs, includes number '0' and boolean 'true')
// ---------------------------------------------------------------------------
export const mockMachineCustomFieldValues: CustomFieldValue[] = [
{
id: 'mcfv-001',
value: 'SN-2025-001234',
customField: mockMachineCustomFieldDefs[0]!,
},
{
id: 'mcfv-002',
value: 'true',
customField: mockMachineCustomFieldDefs[1]!,
},
{
id: 'mcfv-003',
value: '0',
customField: mockMachineCustomFieldDefs[2]!,
},
{
id: 'mcfv-004',
value: 'Zone 1',
customField: mockMachineCustomFieldDefs[3]!,
},
{
id: 'mcfv-005',
value: '2025-01-15',
customField: mockMachineCustomFieldDefs[4]!,
},
]

View File

@@ -1,508 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
toFieldString,
fieldKey,
resolveFieldName,
resolveFieldType,
resolveRequiredFlag,
resolveOptions,
resolveDefaultValue,
formatDefaultValue,
normalizeCustomField,
normalizeCustomFieldInputs,
extractStoredCustomFieldValue,
buildCustomFieldInputs,
requiredCustomFieldsFilled,
shouldPersistField,
formatValueForPersistence,
buildCustomFieldMetadata,
type CustomFieldInput,
} from '~/shared/utils/customFieldFormUtils'
// ---------------------------------------------------------------------------
// toFieldString
// ---------------------------------------------------------------------------
describe('toFieldString', () => {
it('returns empty string for null', () => {
expect(toFieldString(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(toFieldString(undefined)).toBe('')
})
it('returns string as-is', () => {
expect(toFieldString('hello')).toBe('hello')
})
it('converts number to string', () => {
expect(toFieldString(42)).toBe('42')
})
it('converts boolean to string', () => {
expect(toFieldString(true)).toBe('true')
expect(toFieldString(false)).toBe('false')
})
it('returns empty string for objects', () => {
expect(toFieldString({ foo: 'bar' })).toBe('')
})
})
// ---------------------------------------------------------------------------
// fieldKey
// ---------------------------------------------------------------------------
describe('fieldKey', () => {
it('prefers customFieldValueId', () => {
const field = { customFieldValueId: 'cfv-1', id: 'id-1', name: 'field' } as CustomFieldInput
expect(fieldKey(field, 0)).toBe('cfv-1')
})
it('falls back to id', () => {
const field = { customFieldValueId: null, id: 'id-1', name: 'field' } as CustomFieldInput
expect(fieldKey(field, 0)).toBe('id-1')
})
it('falls back to name-index', () => {
const field = { customFieldValueId: null, id: null, name: 'weight' } as CustomFieldInput
expect(fieldKey(field, 3)).toBe('weight-3')
})
})
// ---------------------------------------------------------------------------
// resolveFieldName
// ---------------------------------------------------------------------------
describe('resolveFieldName', () => {
it('resolves from name property', () => {
expect(resolveFieldName({ name: 'Poids' })).toBe('Poids')
})
it('falls back to key', () => {
expect(resolveFieldName({ key: 'poids' })).toBe('poids')
})
it('falls back to label', () => {
expect(resolveFieldName({ label: 'Poids' })).toBe('Poids')
})
it('returns empty string for empty object', () => {
expect(resolveFieldName({})).toBe('')
})
it('returns empty string for null', () => {
expect(resolveFieldName(null)).toBe('')
})
it('trims whitespace', () => {
expect(resolveFieldName({ name: ' Poids ' })).toBe('Poids')
})
})
// ---------------------------------------------------------------------------
// resolveFieldType
// ---------------------------------------------------------------------------
describe('resolveFieldType', () => {
it('resolves valid type', () => {
expect(resolveFieldType({ type: 'number' })).toBe('number')
})
it('resolves case-insensitive', () => {
expect(resolveFieldType({ type: 'SELECT' })).toBe('select')
})
it('falls back to text for unknown type', () => {
expect(resolveFieldType({ type: 'blob' })).toBe('text')
})
it('resolves nested value.type', () => {
expect(resolveFieldType({ value: { type: 'date' } })).toBe('date')
})
it('returns text for missing type', () => {
expect(resolveFieldType({})).toBe('text')
})
it('handles all allowed types', () => {
for (const type of ['text', 'number', 'select', 'boolean', 'date']) {
expect(resolveFieldType({ type })).toBe(type)
}
})
})
// ---------------------------------------------------------------------------
// resolveRequiredFlag
// ---------------------------------------------------------------------------
describe('resolveRequiredFlag', () => {
it('resolves boolean true', () => {
expect(resolveRequiredFlag({ required: true })).toBe(true)
})
it('resolves boolean false', () => {
expect(resolveRequiredFlag({ required: false })).toBe(false)
})
it('resolves nested value.required', () => {
expect(resolveRequiredFlag({ value: { required: true } })).toBe(true)
})
it('resolves string "true"', () => {
expect(resolveRequiredFlag({ value: { required: 'true' } })).toBe(true)
})
it('resolves string "1"', () => {
expect(resolveRequiredFlag({ value: { required: '1' } })).toBe(true)
})
it('defaults to false', () => {
expect(resolveRequiredFlag({})).toBe(false)
})
})
// ---------------------------------------------------------------------------
// resolveOptions
// ---------------------------------------------------------------------------
describe('resolveOptions', () => {
it('resolves array of strings', () => {
expect(resolveOptions({ options: ['A', 'B'] })).toEqual(['A', 'B'])
})
it('resolves array of objects with value key', () => {
expect(resolveOptions({ options: [{ value: 'A' }, { value: 'B' }] })).toEqual(['A', 'B'])
})
it('resolves array of objects with label key', () => {
expect(resolveOptions({ options: [{ label: 'Foo' }] })).toEqual(['Foo'])
})
it('falls back to value.options', () => {
expect(resolveOptions({ value: { options: ['X'] } })).toEqual(['X'])
})
it('falls back to value.choices', () => {
expect(resolveOptions({ value: { choices: ['Y'] } })).toEqual(['Y'])
})
it('returns empty array for no options', () => {
expect(resolveOptions({})).toEqual([])
})
it('filters out empty strings', () => {
expect(resolveOptions({ options: ['A', '', 'B'] })).toEqual(['A', 'B'])
})
it('filters out null values', () => {
expect(resolveOptions({ options: [null, 'A'] })).toEqual(['A'])
})
})
// ---------------------------------------------------------------------------
// resolveDefaultValue
// ---------------------------------------------------------------------------
describe('resolveDefaultValue', () => {
it('returns null for null input', () => {
expect(resolveDefaultValue(null)).toBeNull()
})
it('resolves defaultValue', () => {
expect(resolveDefaultValue({ defaultValue: 'hello' })).toBe('hello')
})
it('resolves value (non-object)', () => {
expect(resolveDefaultValue({ value: 42 })).toBe(42)
})
it('resolves nested value.defaultValue', () => {
expect(resolveDefaultValue({ value: { defaultValue: 'nested' } })).toBe('nested')
})
it('returns null when nothing found', () => {
expect(resolveDefaultValue({})).toBeNull()
})
})
// ---------------------------------------------------------------------------
// formatDefaultValue
// ---------------------------------------------------------------------------
describe('formatDefaultValue', () => {
it('returns empty string for null', () => {
expect(formatDefaultValue('text', null)).toBe('')
})
it('converts number to string', () => {
expect(formatDefaultValue('number', 42)).toBe('42')
})
it('handles boolean type with true', () => {
expect(formatDefaultValue('boolean', 'true')).toBe('true')
expect(formatDefaultValue('boolean', true)).toBe('true')
expect(formatDefaultValue('boolean', '1')).toBe('true')
})
it('handles boolean type with false', () => {
expect(formatDefaultValue('boolean', 'false')).toBe('false')
expect(formatDefaultValue('boolean', false)).toBe('false')
expect(formatDefaultValue('boolean', '0')).toBe('false')
})
it('unwraps nested defaultValue object', () => {
expect(formatDefaultValue('text', { defaultValue: 'inner' })).toBe('inner')
})
})
// ---------------------------------------------------------------------------
// normalizeCustomField
// ---------------------------------------------------------------------------
describe('normalizeCustomField', () => {
it('normalizes a complete field', () => {
const result = normalizeCustomField({
id: 'cf-1',
name: 'Weight',
type: 'number',
required: true,
options: [],
orderIndex: 2,
})
expect(result).toEqual({
id: 'cf-1',
name: 'Weight',
type: 'number',
required: true,
options: [],
value: '',
customFieldId: 'cf-1',
customFieldValueId: null,
orderIndex: 2,
})
})
it('returns null for null input', () => {
expect(normalizeCustomField(null)).toBeNull()
})
it('returns null for field without name', () => {
expect(normalizeCustomField({ type: 'text' })).toBeNull()
})
it('uses fallback index', () => {
const result = normalizeCustomField({ name: 'Test' }, 5)
expect(result?.orderIndex).toBe(5)
})
it('defaults type to text', () => {
const result = normalizeCustomField({ name: 'Field' })
expect(result?.type).toBe('text')
})
})
// ---------------------------------------------------------------------------
// normalizeCustomFieldInputs
// ---------------------------------------------------------------------------
describe('normalizeCustomFieldInputs', () => {
it('returns empty array for null structure', () => {
expect(normalizeCustomFieldInputs(null)).toEqual([])
})
it('returns empty array for structure without customFields', () => {
expect(normalizeCustomFieldInputs({})).toEqual([])
})
it('normalizes and sorts fields by orderIndex', () => {
const result = normalizeCustomFieldInputs({
customFields: [
{ name: 'B', orderIndex: 2 },
{ name: 'A', orderIndex: 1 },
],
})
expect(result).toHaveLength(2)
expect(result[0].name).toBe('A')
expect(result[1].name).toBe('B')
})
it('filters out invalid fields', () => {
const result = normalizeCustomFieldInputs({
customFields: [{ name: 'Valid' }, null, { type: 'text' }],
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('Valid')
})
})
// ---------------------------------------------------------------------------
// extractStoredCustomFieldValue
// ---------------------------------------------------------------------------
describe('extractStoredCustomFieldValue', () => {
it('returns string directly', () => {
expect(extractStoredCustomFieldValue('hello')).toBe('hello')
})
it('returns number directly', () => {
expect(extractStoredCustomFieldValue(42)).toBe(42)
})
it('returns empty string for null', () => {
expect(extractStoredCustomFieldValue(null)).toBe('')
})
it('extracts .value from object', () => {
expect(extractStoredCustomFieldValue({ value: 'test' })).toBe('test')
})
it('extracts nested .value.value', () => {
expect(extractStoredCustomFieldValue({ value: { value: 'deep' } })).toBe('deep')
})
it('extracts customFieldValue.value', () => {
expect(extractStoredCustomFieldValue({ customFieldValue: { value: 'cfv' } })).toBe('cfv')
})
})
// ---------------------------------------------------------------------------
// buildCustomFieldInputs
// ---------------------------------------------------------------------------
describe('buildCustomFieldInputs', () => {
const definitions = {
customFields: [
{ name: 'Weight', type: 'number', required: true, id: 'cf-1', orderIndex: 0 },
{ name: 'Color', type: 'select', options: ['Red', 'Blue'], id: 'cf-2', orderIndex: 1 },
],
}
it('builds inputs from definitions without values', () => {
const result = buildCustomFieldInputs(definitions, null)
expect(result).toHaveLength(2)
expect(result[0].name).toBe('Weight')
expect(result[0].value).toBe('')
expect(result[1].name).toBe('Color')
})
it('merges stored values by id', () => {
const values = [
{ customField: { id: 'cf-1', name: 'Weight' }, id: 'cfv-1', value: '42' },
]
const result = buildCustomFieldInputs(definitions, values)
expect(result[0].value).toBe('42')
expect(result[0].customFieldValueId).toBe('cfv-1')
})
it('merges stored values by name fallback', () => {
const values = [
{ name: 'Color', id: 'cfv-2', value: 'Blue' },
]
const result = buildCustomFieldInputs(definitions, values)
expect(result[1].value).toBe('Blue')
})
it('returns empty array for null structure', () => {
expect(buildCustomFieldInputs(null, [])).toEqual([])
})
})
// ---------------------------------------------------------------------------
// requiredCustomFieldsFilled
// ---------------------------------------------------------------------------
describe('requiredCustomFieldsFilled', () => {
it('returns true when all required fields are filled', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'A', type: 'text', required: true, options: [], value: 'hello', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
})
it('returns false when required field is empty', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'A', type: 'text', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
})
it('returns true for non-required empty field', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'A', type: 'text', required: false, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
})
it('treats boolean "false" as filled', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'false', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
})
it('treats boolean "true" as filled', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: 'true', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(true)
})
it('treats boolean empty as not filled', () => {
const inputs: CustomFieldInput[] = [
{ id: null, name: 'Active', type: 'boolean', required: true, options: [], value: '', customFieldId: null, customFieldValueId: null, orderIndex: 0 },
]
expect(requiredCustomFieldsFilled(inputs)).toBe(false)
})
it('returns true for empty array', () => {
expect(requiredCustomFieldsFilled([])).toBe(true)
})
})
// ---------------------------------------------------------------------------
// shouldPersistField & formatValueForPersistence
// ---------------------------------------------------------------------------
describe('shouldPersistField', () => {
it('returns true for non-empty text field', () => {
expect(shouldPersistField({ value: 'hello' } as CustomFieldInput)).toBe(true)
})
it('returns false for empty text field', () => {
expect(shouldPersistField({ value: '', type: 'text' } as CustomFieldInput)).toBe(false)
})
it('returns true for boolean "true"', () => {
expect(shouldPersistField({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe(true)
})
it('returns true for boolean "false"', () => {
expect(shouldPersistField({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe(true)
})
it('returns false for boolean empty', () => {
expect(shouldPersistField({ value: '', type: 'boolean' } as CustomFieldInput)).toBe(false)
})
})
describe('formatValueForPersistence', () => {
it('trims text value', () => {
expect(formatValueForPersistence({ value: ' hello ', type: 'text' } as CustomFieldInput)).toBe('hello')
})
it('returns "true" for boolean true', () => {
expect(formatValueForPersistence({ value: 'true', type: 'boolean' } as CustomFieldInput)).toBe('true')
})
it('returns "false" for boolean non-true', () => {
expect(formatValueForPersistence({ value: 'false', type: 'boolean' } as CustomFieldInput)).toBe('false')
expect(formatValueForPersistence({ value: '', type: 'boolean' } as CustomFieldInput)).toBe('false')
})
})
// ---------------------------------------------------------------------------
// buildCustomFieldMetadata
// ---------------------------------------------------------------------------
describe('buildCustomFieldMetadata', () => {
it('builds metadata from field', () => {
const field: CustomFieldInput = {
id: null, name: 'Color', type: 'select', required: true,
options: ['Red', 'Blue'], value: 'Red',
customFieldId: null, customFieldValueId: null, orderIndex: 0,
}
expect(buildCustomFieldMetadata(field)).toEqual({
customFieldName: 'Color',
customFieldType: 'select',
customFieldRequired: true,
customFieldOptions: ['Red', 'Blue'],
})
})
})

View File

@@ -64,6 +64,7 @@ RUN rm -f /etc/nginx/sites-enabled/default
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/inventory.conf
COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
COPY infra/prod/deploy.sh /var/www/html/infra/prod/deploy.sh
# Backend from stage 1
COPY --from=backend-build /app /var/www/html

View File

@@ -6,6 +6,28 @@ cd "$(dirname "$0")"
TAG="${1:-latest}"
export INVENTORY_IMAGE_TAG="$TAG"
# --- Self-update from Docker image ---
# After pulling the new image, extracts this script from it.
# If the version inside the image differs, replaces itself and re-executes.
if [ "${_DEPLOY_UPDATED:-0}" != "1" ]; then
echo "==> Pulling image..."
sudo docker compose pull
SELF="$(realpath "$0")"
TEMP_SCRIPT="$(mktemp)"
sudo docker compose run --rm --no-deps -T --entrypoint cat app /var/www/html/infra/prod/deploy.sh > "$TEMP_SCRIPT" 2>/dev/null || true
if [ -s "$TEMP_SCRIPT" ] && ! diff -q "$SELF" "$TEMP_SCRIPT" > /dev/null 2>&1; then
echo "==> deploy.sh updated from image, re-executing..."
cp "$TEMP_SCRIPT" "$SELF"
chmod +x "$SELF"
rm -f "$TEMP_SCRIPT"
export _DEPLOY_UPDATED=1
exec "$SELF" "$@"
fi
rm -f "$TEMP_SCRIPT"
fi
echo "==> Deploying inventory:${TAG}..."
# Fix storage directory structure (one-time migration fix)
@@ -24,8 +46,11 @@ sudo chown -R www-data:www-data storage
echo "==> Enabling maintenance mode..."
touch maintenance.on
echo "==> Pulling image..."
sudo docker compose pull
# Pull image (skip if already pulled during self-update)
if [ "${_DEPLOY_UPDATED:-0}" != "1" ]; then
echo "==> Pulling image..."
sudo docker compose pull
fi
echo "==> Starting container..."
sudo docker compose up -d

View File

@@ -127,6 +127,12 @@ php-cs-fixer-allow-risky:
test:
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
test-front:
cd frontend && npx vitest run $(FILES)
test-front-watch:
cd frontend && npx vitest --watch $(FILES)
test-setup:
$(SYMFONY_CONSOLE) doctrine:database:create --if-not-exists --env=test
$(SYMFONY_CONSOLE) doctrine:schema:update --force --env=test

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Re-link orphaned CustomField records to their ModelType.
*
* Legacy data created one CustomField definition per composant/piece instead
* of sharing a single definition on the ModelType. This migration:
* 1. Finds orphaned CustomField used by composants → elects one canonical per (ModelType, name)
* 2. Same for pieces
* 3. Re-points CustomFieldValue to the canonical
* 4. Sets typecomposantid / typepieceid on the canonical
* 5. Deletes now-unused duplicate CustomField rows
*/
final class Version20260404_RelinkOrphanedCustomFields extends AbstractMigration
{
public function getDescription(): string
{
return 'Re-link orphaned custom_fields to their model_types via composants and pieces';
}
public function up(Schema $schema): void
{
// ── COMPOSANTS ──────────────────────────────────────────────────
// For each (model_type, field_name) group of orphaned fields used by composants,
// pick the one with the smallest id as canonical, set its typecomposantid,
// re-point all values to it, then delete the duplicates.
$this->addSql(<<<'SQL'
-- Step 1: Create temp table with canonical mapping for COMPOSANT orphans
CREATE TEMP TABLE _cf_composant_canonical AS
SELECT DISTINCT ON (c.typecomposantid, cf.name)
cf.id AS canonical_id,
c.typecomposantid AS mt_id,
cf.name AS cf_name
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN composants c ON c.id = cfv.composantid
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND c.typecomposantid IS NOT NULL
ORDER BY c.typecomposantid, cf.name, cf.id;
SQL);
$this->addSql(<<<'SQL'
-- Step 2: Create temp table listing ALL orphaned composant field ids with their canonical
CREATE TEMP TABLE _cf_composant_all AS
SELECT cf.id AS orphan_id, canon.canonical_id
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN composants c ON c.id = cfv.composantid
INNER JOIN _cf_composant_canonical canon
ON canon.mt_id = c.typecomposantid AND canon.cf_name = cf.name
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND c.typecomposantid IS NOT NULL
AND cf.id != canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
-- Step 3: Re-point custom_field_values from duplicates to canonical
UPDATE custom_field_values cfv
SET customfieldid = dup.canonical_id
FROM _cf_composant_all dup
WHERE cfv.customfieldid = dup.orphan_id;
SQL);
$this->addSql(<<<'SQL'
-- Step 4: Set typecomposantid on canonical fields
UPDATE custom_fields cf
SET typecomposantid = canon.mt_id
FROM _cf_composant_canonical canon
WHERE cf.id = canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
-- Step 5: Delete duplicate (non-canonical) custom_fields for composants
DELETE FROM custom_fields
WHERE id IN (SELECT orphan_id FROM _cf_composant_all);
SQL);
$this->addSql('DROP TABLE IF EXISTS _cf_composant_all');
$this->addSql('DROP TABLE IF EXISTS _cf_composant_canonical');
// ── PIECES ───────────<E29480><E29480><EFBFBD>────────────────────────────<E29480><E29480><EFBFBD>─────────────
$this->addSql(<<<'SQL'
CREATE TEMP TABLE _cf_piece_canonical AS
SELECT DISTINCT ON (p.typepieceid, cf.name)
cf.id AS canonical_id,
p.typepieceid AS mt_id,
cf.name AS cf_name
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN pieces p ON p.id = cfv.pieceid
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND p.typepieceid IS NOT NULL
ORDER BY p.typepieceid, cf.name, cf.id;
SQL);
$this->addSql(<<<'SQL'
CREATE TEMP TABLE _cf_piece_all AS
SELECT cf.id AS orphan_id, canon.canonical_id
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN pieces p ON p.id = cfv.pieceid
INNER JOIN _cf_piece_canonical canon
ON canon.mt_id = p.typepieceid AND canon.cf_name = cf.name
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND p.typepieceid IS NOT NULL
AND cf.id != canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
UPDATE custom_field_values cfv
SET customfieldid = dup.canonical_id
FROM _cf_piece_all dup
WHERE cfv.customfieldid = dup.orphan_id;
SQL);
$this->addSql(<<<'SQL'
UPDATE custom_fields cf
SET typepieceid = canon.mt_id
FROM _cf_piece_canonical canon
WHERE cf.id = canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
DELETE FROM custom_fields
WHERE id IN (SELECT orphan_id FROM _cf_piece_all);
SQL);
$this->addSql('DROP TABLE IF EXISTS _cf_piece_all');
$this->addSql('DROP TABLE IF EXISTS _cf_piece_canonical');
}
public function down(Schema $schema): void
{
// Data migration — not reversible. The old per-entity duplicates are gone.
$this->addSql('SELECT 1');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Migrate constructeur associations from legacy ManyToMany join tables
* (_composantconstructeurs, _piececonstructeurs, _machineconstructeurs, _productconstructeurs)
* to the new ConstructeurLink entity tables.
*/
final class Version20260405_MigrateConstructeurLinks extends AbstractMigration
{
public function getDescription(): string
{
return 'Migrate constructeur links from legacy M2M tables to new link entity tables';
}
public function up(Schema $schema): void
{
// No-op: legacy M2M tables (_composantconstructeurs, _piececonstructeurs, etc.)
// were already dropped by Version20260331121257.
// The actual data restoration is handled by Version20260405_RestoreConstructeurLinksFromBackup.
}
public function down(Schema $schema): void
{
// No-op
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Fallback migration: if the legacy M2M tables (_composantconstructeurs, _piececonstructeurs)
* no longer exist in production, this migration inserts the links directly from the backup (3) data.
*
* Run AFTER Version20260405_MigrateConstructeurLinks (which handles the case where legacy tables exist).
* This migration only inserts links that don't already exist (ON CONFLICT DO NOTHING).
*
* Note: Some entity IDs from the backup may no longer exist (deleted composants/pieces).
* The migration disables FK checks temporarily to avoid failures, then cleans up orphans.
*/
final class Version20260405_RestoreConstructeurLinksFromBackup extends AbstractMigration
{
public function getDescription(): string
{
return 'Restore constructeur links from backup (3) data — fallback if legacy M2M tables are gone';
}
public function up(Schema $schema): void
{
// Temporarily disable FK checks
$this->addSql('SET session_replication_role = replica');
// === COMPOSANT-CONSTRUCTEUR LINKS (5 from backup 3) ===
$this->addSql("
INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat)
VALUES
('clbkp3_cc_001', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgqp5dvp00014705qpkci8qc', NULL, NOW(), NOW()),
('clbkp3_cc_002', 'cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm', NULL, NOW(), NOW()),
('clbkp3_cc_003', 'cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm', NULL, NOW(), NOW()),
('clbkp3_cc_004', 'cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj', NULL, NOW(), NOW()),
('clbkp3_cc_005', 'cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW())
ON CONFLICT (id) DO NOTHING
");
// === PIECE-CONSTRUCTEUR LINKS (25 from backup 3) ===
$this->addSql("
INSERT INTO piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat)
VALUES
('clbkp3_pc_001', 'cmizudzfy00021e2w2mtd9zv8', 'cmizu5ugx00011e2wjpr6nb3k', NULL, NOW(), NOW()),
('clbkp3_pc_002', 'cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_003', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic', NULL, NOW(), NOW()),
('clbkp3_pc_004', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3', NULL, NOW(), NOW()),
('clbkp3_pc_005', 'cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh', NULL, NOW(), NOW()),
('clbkp3_pc_006', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql', NULL, NOW(), NOW()),
('clbkp3_pc_007', 'cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic', NULL, NOW(), NOW()),
('clbkp3_pc_008', 'cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_009', 'cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_010', 'clcff0f15790b2c7084f781df6', 'cl219849fbab8bbaf6163f5700', NULL, NOW(), NOW()),
('clbkp3_pc_011', 'cl4807538979ddd27099d77578', 'cl219849fbab8bbaf6163f5700', NULL, NOW(), NOW()),
('clbkp3_pc_012', 'cl960c6ffcfabd9eeb2b1452ab', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_013', 'cl5b928245d51ff4f037f6cc6d', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_014', 'cl92edc1a20a7fd0f1355fd476', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_015', 'cl811abd3d9d8ba63585424906', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_016', 'clfd2b5e2570b0be44f7196870', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_017', 'cl0ba5ceffb2e5496624087d85', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_018', 'cl50fe870a07e42759b37b511f', 'cl219849fbab8bbaf6163f5700', NULL, NOW(), NOW()),
('clbkp3_pc_019', 'cl9de983224260763d7ea6fe95', 'cmkqpnznr001p1eq6hdh2ept8', NULL, NOW(), NOW()),
('clbkp3_pc_020', 'clbd07ebf2568ea14ac792ba49', 'cmhaac3vo003547v7s1wv6jhv', NULL, NOW(), NOW()),
('clbkp3_pc_021', 'cl754c25154e5546882a7d6706', 'cmhaac3vo003547v7s1wv6jhv', NULL, NOW(), NOW()),
('clbkp3_pc_022', 'clc8fa00057b54d782c06aebd0', 'cmhaac3vo003547v7s1wv6jhv', NULL, NOW(), NOW()),
('clbkp3_pc_023', 'cl6480b97f6516fba22ce86434', 'cmg93n9sk000047uuwm6u20mj', NULL, NOW(), NOW()),
('clbkp3_pc_024', 'cl9579b05774d92096117841b0', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_025', 'cl5b02c64fcc5ae8a3bfb6e5e6', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW())
ON CONFLICT (id) DO NOTHING
");
// Note: 6 additional Limatech piece links from backup (3) used old Limatech ID 'cla14aa4a50a799c2e54391be7'.
// Limatech was recreated with a new ID. These links use the current Limatech ID (cmizv4lm500071e2w6xymi2p6)
// for the last 2 entries above. The remaining 4 Limatech links:
$this->addSql("
INSERT INTO piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat)
VALUES
('clbkp3_pc_026', 'cle788fea147886d499676b745', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_027', 'clfd3cb41a407ab5a3f9d5baae', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_028', 'cld08dae22796b5855152580d9', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW()),
('clbkp3_pc_029', 'clf6012fca41994c1e81ce2dba', 'cmizv4lm500071e2w6xymi2p6', NULL, NOW(), NOW())
ON CONFLICT (id) DO NOTHING
");
// Re-enable FK checks
$this->addSql('SET session_replication_role = DEFAULT');
// Clean up orphaned rows (entities that no longer exist)
$this->addSql("
DELETE FROM composant_constructeur_links
WHERE id LIKE 'clbkp3_%'
AND composantid NOT IN (SELECT id FROM composants)
");
$this->addSql("
DELETE FROM piece_constructeur_links
WHERE id LIKE 'clbkp3_%'
AND pieceid NOT IN (SELECT id FROM pieces)
");
$this->addSql("
DELETE FROM composant_constructeur_links
WHERE id LIKE 'clbkp3_%'
AND constructeurid NOT IN (SELECT id FROM constructeurs)
");
$this->addSql("
DELETE FROM piece_constructeur_links
WHERE id LIKE 'clbkp3_%'
AND constructeurid NOT IN (SELECT id FROM constructeurs)
");
}
public function down(Schema $schema): void
{
$this->addSql("DELETE FROM composant_constructeur_links WHERE id LIKE 'clbkp3_%'");
$this->addSql("DELETE FROM piece_constructeur_links WHERE id LIKE 'clbkp3_%'");
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\ComposantConstructeurLink;
use App\Entity\Constructeur;
use App\Entity\MachineConstructeurLink;
use App\Entity\PieceConstructeurLink;
use App\Entity\ProductConstructeurLink;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ConstructeurStatsController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/constructeurs/{id}/stats', name: 'api_constructeur_stats', methods: ['GET'])]
public function stats(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$constructeur = $this->em->find(Constructeur::class, $id);
if (!$constructeur) {
return new JsonResponse(
['message' => 'Fournisseur introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$bulk = $this->fetchAllStats();
return new JsonResponse($bulk[$id] ?? [
'composantCount' => 0,
'pieceCount' => 0,
'machineCount' => 0,
'productCount' => 0,
]);
}
#[Route('/api/constructeurs/stats', name: 'api_constructeurs_stats_bulk', methods: ['GET'], priority: 1)]
public function bulkStats(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
return new JsonResponse($this->fetchAllStats());
}
/** @return array<string, array{composantCount: int, pieceCount: int, machineCount: int, productCount: int}> */
private function fetchAllStats(): array
{
$result = [];
// Initialize all constructeurs with zero counts
$allIds = $this->em->createQuery(
'SELECT c.id FROM App\Entity\Constructeur c',
)->getSingleColumnResult();
foreach ($allIds as $id) {
$result[$id] = [
'composantCount' => 0,
'pieceCount' => 0,
'machineCount' => 0,
'productCount' => 0,
];
}
// 4 bulk queries instead of 4N
$counts = [
'composantCount' => ComposantConstructeurLink::class,
'pieceCount' => PieceConstructeurLink::class,
'machineCount' => MachineConstructeurLink::class,
'productCount' => ProductConstructeurLink::class,
];
foreach ($counts as $key => $entityClass) {
$rows = $this->em->createQuery(
'SELECT IDENTITY(l.constructeur) AS cid, COUNT(l.id) AS cnt FROM '.$entityClass.' l GROUP BY l.constructeur',
)->getResult();
foreach ($rows as $row) {
if (isset($result[$row['cid']])) {
$result[$row['cid']][$key] = (int) $row['cnt'];
}
}
}
return $result;
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Composant;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\Piece;
use App\Entity\Product;
use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/model_types/{id}')]
final class ModelTypeRelatedItemsController extends AbstractController
{
public function __construct(
private readonly ModelTypeRepository $modelTypes,
private readonly EntityManagerInterface $em,
) {}
#[Route('/related-items', name: 'api_model_type_related_items', methods: ['GET'])]
public function relatedItems(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
return new JsonResponse(
['message' => 'Catégorie introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$items = match ($modelType->getCategory()) {
ModelCategory::COMPONENT => $this->fetchComposantsWithMachineCount($id),
ModelCategory::PIECE => $this->fetchPiecesWithMachineCount($id),
ModelCategory::PRODUCT => $this->fetchProductsWithMachineCount($id),
};
return new JsonResponse($items);
}
/**
* @return list<array{id: string, name: string, reference: null|string, machineCount: int}>
*/
private function fetchComposantsWithMachineCount(string $modelTypeId): array
{
$qb = $this->em->createQueryBuilder();
$qb->select(
'c.id',
'c.name',
'c.reference',
'COALESCE(c.referenceAuto, c.reference) as displayReference',
'COUNT(DISTINCT mcl.id) as machineCount',
)
->from(Composant::class, 'c')
->leftJoin(MachineComponentLink::class, 'mcl', 'WITH', 'mcl.composant = c')
->where('c.typeComposant = :modelTypeId')
->setParameter('modelTypeId', $modelTypeId)
->groupBy('c.id', 'c.name', 'c.reference', 'c.referenceAuto')
->orderBy('c.name', 'ASC')
;
return $this->formatResults($qb->getQuery()->getArrayResult());
}
/**
* @return list<array{id: string, name: string, reference: null|string, machineCount: int}>
*/
private function fetchPiecesWithMachineCount(string $modelTypeId): array
{
$qb = $this->em->createQueryBuilder();
$qb->select(
'p.id',
'p.name',
'p.reference',
'COALESCE(p.referenceAuto, p.reference) as displayReference',
'COUNT(DISTINCT mpl.id) as machineCount',
)
->from(Piece::class, 'p')
->leftJoin(MachinePieceLink::class, 'mpl', 'WITH', 'mpl.piece = p')
->where('p.typePiece = :modelTypeId')
->setParameter('modelTypeId', $modelTypeId)
->groupBy('p.id', 'p.name', 'p.reference', 'p.referenceAuto')
->orderBy('p.name', 'ASC')
;
return $this->formatResults($qb->getQuery()->getArrayResult());
}
/**
* @return list<array{id: string, name: string, reference: null|string, machineCount: int}>
*/
private function fetchProductsWithMachineCount(string $modelTypeId): array
{
$qb = $this->em->createQueryBuilder();
$qb->select(
'pr.id',
'pr.name',
'pr.reference',
'COUNT(DISTINCT mpl.id) as machineCount',
)
->from(Product::class, 'pr')
->leftJoin(MachineProductLink::class, 'mpl', 'WITH', 'mpl.product = pr')
->where('pr.typeProduct = :modelTypeId')
->setParameter('modelTypeId', $modelTypeId)
->groupBy('pr.id', 'pr.name', 'pr.reference')
->orderBy('pr.name', 'ASC')
;
return $this->formatResults($qb->getQuery()->getArrayResult());
}
/**
* @param array<int, array<string, mixed>> $rows
*
* @return list<array{id: string, name: string, reference: null|string, machineCount: int}>
*/
private function formatResults(array $rows): array
{
return array_values(array_map(
static fn (array $row): array => [
'id' => (string) $row['id'],
'name' => (string) ($row['name'] ?? ''),
'reference' => isset($row['displayReference']) && '' !== $row['displayReference']
? (string) $row['displayReference']
: (isset($row['reference']) && '' !== $row['reference'] ? (string) $row['reference'] : null),
'machineCount' => (int) $row['machineCount'],
],
$rows,
));
}
}

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