Compare commits

...

384 Commits

Author SHA1 Message Date
gitea-actions
3f5e4b7f51 chore : bump version to v1.9.17
All checks were successful
Auto Tag Develop / tag (push) Successful in 16s
Build & Push Docker Image / build (push) Successful in 38s
2026-04-06 09:23:53 +00:00
0832af86cc Merge branch 'feat/ux-quick-wins' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
# Conflicts:
#	frontend/app/components/ComponentItem.vue
#	frontend/app/components/PieceItem.vue
#	frontend/app/components/machine/MachineInfoCard.vue
#	frontend/app/composables/usePieceEdit.ts
#	frontend/app/pages/component/[id]/index.vue
#	frontend/app/pages/piece/[id].vue
#	frontend/app/pages/product/[id]/edit.vue
#	frontend/app/pages/product/[id]/index.vue
#	frontend/app/pages/product/create.vue
#	frontend/app/shared/utils/customFields.ts
2026-04-06 11:23:21 +02:00
44b6e0998c fix(custom-fields) : fix declaration order in useComponentEdit and useComponentCreate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:20:08 +02:00
c4ed8c8edc refactor(custom-fields) : unify 3 parallel implementations into 1 module
Replace ~2900 lines across 9 files with ~400 lines in 2 files:
- shared/utils/customFields.ts (types + pure helpers)
- composables/useCustomFieldInputs.ts (reactive composable)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:47:04 +02:00
gitea-actions
8565e68062 chore : bump version to v1.9.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 23s
Build Release Artefact / build (push) Successful in 2m5s
2026-04-01 12:36:42 +00:00
Matthieu
a8a95b16a9 fix : mount var/storage/documents volume instead of var/uploads
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:36:30 +02:00
gitea-actions
68b394fc14 chore : bump version to v1.9.5
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 34s
Build Release Artefact / build (push) Successful in 1m41s
2026-04-01 12:28:32 +00:00
Matthieu
2ceb49db9f fix : use REGISTRY_TOKEN instead of RELEASE_TOKEN in CI workflows
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:28:20 +02:00
Matthieu
8ad0f26249 feat : add auto-tag and release artefact CI workflows
Some checks failed
Auto Tag Develop / tag (push) Failing after 4s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:23:12 +02:00
Matthieu
be859e57db refactor : rename Inventory_frontend to frontend in docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:19 +02:00
Matthieu
73b2cf300d refactor : rename Inventory_frontend to frontend everywhere
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m4s
Update Dockerfile, nginx, .dockerignore, makefile, CI workflow,
CLAUDE.md, README, release script to use frontend/ instead of
Inventory_frontend/. Remove submodule references from CI and
release script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:19:20 +02:00
Matthieu
974a4a0781 refactor : merge Inventory_frontend submodule into frontend/ directory
Merges the full git history of Inventory_frontend into the monorepo
under frontend/. Removes the submodule in favor of a unified repo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:17:57 +02:00
Matthieu
faa7511cab refactor : remove Inventory_frontend submodule (preparing merge)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:15:28 +02:00
Matthieu
958a00c8fc WIP 2026-03-31 17:53:30 +02:00
Matthieu
e0f761da2b feat(constructeur) : update product edit flow with supplier references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:02:32 +02:00
Matthieu
80739a4528 feat(constructeur) : update composant edit flow with supplier references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:52:50 +02:00
Matthieu
c5988ec7a6 feat(constructeur) : update piece edit flow with supplier references
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:55:18 +02:00
Matthieu
63a56c47ba feat(constructeur) : add ConstructeurLinkEntry type, useConstructeurLinks composable, and ConstructeurLinksTable component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:33:54 +02:00
Matthieu
c82c21c0cd feat(reference-auto) : formula builder component + composant support + changelog v1.9.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:51:22 +02:00
Matthieu
a339e722a6 feat(reference-auto) : display referenceAuto in piece views + formula config in ModelTypeForm
- Piece interface: add referenceAuto field
- piece/[id].vue: read-only display with auto badge
- pieces/[id]/edit.vue: disabled input when referenceAuto is set
- pieces-catalog.vue: new column "Réf. auto"
- PieceItem.vue: badge + detail line for referenceAuto
- ModelTypeForm.vue: formula + required fields config for PIECE category
- modelTypes.ts: add referenceFormula/requiredFieldsForReference to types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:33:33 +01:00
Matthieu
a7415964a7 feat(machine) : single save button + link versioning display
- Replace auto-save-on-blur with single "Enregistrer" button
- Add Cancel button that resets local state
- Expose saveFieldDefinitions via defineExpose on MachineInfoCard
- Remove standalone save button from MachineCustomFieldDefEditor
- Add saveAllMachineCustomFields batch method
- Add submitEdition/cancelEdition/saving/canSubmit to orchestrator
- Show diff summary badges in version list entries
- Show link changes in restore modal description

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:51:34 +01:00
Matthieu
767c9a7424 feat(versioning) : add entity versioning frontend with restore flow
- useEntityVersions composable (list, preview, restore API calls)
- EntityVersionList component with auto-refresh after save
- VersionRestoreModal with context-aware messages per entity type
- Integrate into machine, composant, piece, product detail pages
- Add restore action label to historyDisplayUtils
- Show structure slots in composant/piece consultation mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:58:39 +01:00
Matthieu
d197d30eb0 fix(composant) : preserve skeleton selections on form validation error
Shared module-level loading ref in useComposants caused structureDataLoading
to toggle during submission, unmounting the skeleton assignment UI. On remount,
watchers cleared selections not found in the limited local catalog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:36:07 +01:00
Matthieu
452de8b069 feat(changelog) : add v1.9.4 release notes (detail views)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 10:12:25 +01:00
Matthieu
92141c6564 feat(detail) : add consultation pages for piece, composant, product
Add read-only detail pages with edit/view toggle for piece, composant and
product, matching the existing machine detail pattern. Empty fields and
documents section are hidden in consultation mode. Catalogs and cross-links
updated to point to the new detail pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 10:09:44 +01:00
Matthieu
9e1504ddb7 feat(machine) : add entity history section to machine detail page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:31:25 +01:00
Matthieu
a72279f978 refactor(component-edit) : replace slot auto-save with deferred save on submit
Slot selections (piece, product, subcomponent, quantity) are no longer
saved immediately on change. Instead, edits are stored locally and
persisted together with base fields and custom fields when the user
clicks "Enregistrer les modifications".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:45:18 +01:00
Matthieu
9cc8b28122 feat(search) : display reference alongside name in all entity select components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:21:37 +01:00
Matthieu
02ca3549d5 fix(search) : disable client-side filtering when server-search is active
SearchSelect was filtering results client-side on label only, hiding
server results matched by reference. Add serverSearch prop to bypass
client filter when the API already handles search.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:19:13 +01:00
Matthieu
5485bac339 feat(search) : add server-side search on name + reference in PieceSelect, ProductSelect and ComposantSelect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:03:01 +01:00
Matthieu
d0dc01deb1 feat(search) : add server-side multi-field search (name + reference) for pieces, components and products
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:57:23 +01:00
Matthieu
a76f25321a docs(changelog) : update changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:29 +01:00
Matthieu
2410ebb7dc fix(custom-fields) : preserve defaultValue and IDs in piece structure editor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:26 +01:00
Matthieu
1d6c520945 fix(navigation) : use router.replace after entity creation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:22 +01:00
Matthieu
10ad7b7f41 feat(comments) : add file attachments UI for comments
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:19 +01:00
Matthieu
aebe7ed586 fix(machine-detail) : hide empty sections in consultation mode
Documents, products, components and pieces cards are now hidden when
empty in consultation mode. They remain visible in edit mode so users
can still add items. Addresses Geoffrey's feedback (INV-7).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:08:43 +01:00
Matthieu
5b42bf1504 fix(custom-fields) : use structure.customFields for definition lookup
The definitionSources passed to saveCustomFieldValues were pointing at
properties not serialized by the API (typeComposant.customFields,
typePiece.pieceCustomFields). Changed to structure.customFields which
is the correct serialized path, preventing orphan custom field creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:26:38 +01:00
Matthieu
5ab63e8b27 docs(changelog) : add v1.10.0 release notes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:14:08 +01:00
Matthieu
4db832bc8c feat(documents) : add type column, filter, and edit to documents page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:51:26 +01:00
Matthieu
736a8bccf9 feat(documents) : wire DocumentEditModal and type select in all entity pages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:38:30 +01:00
Matthieu
bd69b37524 feat(documents) : add type badge and edit button to DocumentListInline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:33:59 +01:00
Matthieu
e7402dda4d feat(documents) : add DocumentEditModal component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:32:09 +01:00
Matthieu
6b0d2d1b0a feat(documents) : add type select to DocumentUpload component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:32:09 +01:00
Matthieu
7a4a77e3fc feat(documents) : add document type constants and updateDocument method
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:31:52 +01:00
Matthieu
2e82e854bf feat(machines) : multi-select site checkboxes, alphabetical sort, OR search param
- Replace site dropdown with inline checkboxes for multi-site filtering
- Sort machines alphabetically (localeCompare fr)
- Switch catalog search from ?name= to ?q= for OR search on name/reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:15:16 +01:00
Matthieu
ac860d3165 fix(constructeurs) : always send constructeurs array in PATCH payload 2026-03-23 13:52:39 +01:00
Matthieu
8176635eb8 fix(machine) : use linkId instead of composantId when deleting a component from machine
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:35:43 +01:00
Matthieu
a730a18794 fix(creation) : redirect to edit page after creating composant, piece, or product
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:26:18 +01:00
Matthieu
40d0753637 fix(model-types) : extract error field from 409 response for user-friendly messages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:47:51 +01:00
Matthieu
db630e315b fix(custom-fields) : preserve CustomField ID in piece structure payload
Prevents data loss when saving ModelType: the frontend now sends existing
CustomField IDs so the backend can match them instead of deleting and recreating.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:07:37 +01:00
Matthieu
53530dc16d fix(piece-edit) : stay on page after saving piece
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:12:15 +01:00
Matthieu
974b74ee9f fix(SearchSelect) : render option-description slot even without optionDescription prop
The v-if on resolveDescription() was hiding the entire slot when
optionDescription prop was not provided. Now checks for slot presence
first, allowing custom formatDescription in PieceSelect/ProductSelect/
ComposantSelect to render properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:54:24 +01:00
Matthieu
ab05ce589d fix(ui) : show type name and ref in slot selects, stay on page after save
- PieceSelect, ProductSelect, ComposantSelect: show type name and
  "Ref." prefix in dropdown descriptions (matching create page format)
- Category edit pages (component, piece, product): stay on page after
  successful save instead of navigating back to list
- Component and product edit pages: same — stay on page after save

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:52:02 +01:00
Matthieu
ce3f081a0a refactor(category) : remove quantity field from category structure editor
Quantity is now managed per-component on the component edit page,
not at the category level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:52:49 +01:00
Matthieu
63fba4138e perf(component-edit) : remove redundant full-catalog loads on mount
The 3 loadPieces/loadProducts/loadComposants(200) calls on mount were
redundant since select components now load filtered data server-side.
Removing them eliminates ~3 heavy API calls + constructeur resolution
per page load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:50:34 +01:00
Matthieu
d58a8c2479 feat(component-edit) : add inline quantity input for piece slots
Quantity can now be edited directly on the component edit page next to
each piece selector, instead of only being defined in the category.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:34:51 +01:00
Matthieu
81f7b1a9ac feat(component-edit) : add link to category edit page from component editor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:32:54 +01:00
Matthieu
9e303426a7 fix(slots) : filter slot select options server-side instead of client-side
PieceSelect, ProductSelect and ComposantSelect were loading up to 200
items then filtering client-side by typeId. If the matching items were
not in the first 200, the dropdown appeared empty.

Now each select component uses API Platform filters (typePiece,
typeProduct, typeComposant) to fetch only relevant items server-side,
with local state to avoid overwriting the global catalog cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:59:51 +01:00
Matthieu
d4fc0f1fee fix(slots) : check API response before updating local state on slot selection
The save functions (savePieceSlotSelection, saveProductSlotSelection,
saveSubcomponentSlotSelection) were not checking result.success before
updating local state and showing success toast. Since useApi.patch()
never throws, the catch block was dead code and errors were silently
ignored while the UI showed success.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:31:19 +01:00
Matthieu
f8403ddfbc docs(changelog) : add v1.9.1 release notes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:53:21 +01:00
Matthieu
428da471d1 fix(component-edit) : force reload catalog to display pre-selected slot items
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:32:07 +01:00
Matthieu
271844efb1 feat(component-edit) : add interactive slot selectors for pieces, products and subcomponents
Replace read-only selections display with PieceSelect, ProductSelect, ComposantSelect
components that allow changing the assigned item in each slot directly from the edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:40:11 +01:00
Matthieu
07cad19988 feat(sync) : wire sync flow into category edit pages with confirmation modal 2026-03-13 13:57:58 +01:00
Matthieu
8dacad7a59 refactor(sync) : remove restrictedMode and add sync service + confirmation modal 2026-03-13 13:49:24 +01:00
Matthieu
5912216a89 fix(piece) : persist slot quantity on blur and send prix as string
- Save composant piece slot quantity via PATCH on blur
- Pass slotId through hierarchy and selection entries
- Send prix as string (not number) to match backend expectation
- Show quantity in view mode when > 1
- Allow quantity edit for all pieces (not just root-level)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:19:09 +01:00
Matthieu
139ba183de fix(custom-fields) : include orphan values with embedded definitions in edit pages
After JSON-to-tables migration, custom field definitions not linked to
a ModelType were invisible on edit pages because buildCustomFieldInputs
only mapped over structure definitions. Now also includes values whose
embedded customField definition has no matching structure entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:18:47 +01:00
Matthieu
9fef009610 feat(skeleton) : remove skeleton JSON field references — use structure API field directly
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:11:07 +01:00
Matthieu
4a3bceffa1 feat(machine) : afficher quantité pièces + pièces incluses des composants
- MachinePiecesCard : passer isEditMode au PieceItem + forward event update
- useMachineHierarchy : mapper quantity depuis le backend + construire
  les pièces de structure du composant en lecture seule
- useMachineDetailUpdates : PATCH MachinePieceLink.quantity + fix reference null
- ComponentItem : séparer pièces liées / pièces incluses par défaut
- useEntityDocuments : skip chargement documents pour pièces de structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:22:20 +01:00
Matthieu
50d8dde6d5 fix(piece) : include structure in composant edit PATCH payload for quantity persistence 2026-03-12 15:02:09 +01:00
Matthieu
9b40f9f2c7 feat(piece) : add quantity display and input to composant edit page 2026-03-12 14:40:55 +01:00
Matthieu
721963449b feat(piece) : display and edit quantity on machine piece items 2026-03-12 14:32:50 +01:00
Matthieu
22ba9a8d05 feat(piece) : add quantity input to composant structure editor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:11:58 +01:00
Matthieu
695d56a6d3 feat(piece) : add quantity field to piece types, sanitization and hydration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:08:43 +01:00
Matthieu
5c31045e83 fix(machine) : fix fournisseur display overflow in MachineInfoCard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:56:17 +01:00
Matthieu
b0124c11ba feat(ui) : add site colors, dark mode toggle and card styling improvements
- Site color field with color picker in create/edit modals
- Dark mode theme (mytheme-dark) with toggle in navbar
- Stronger site color visibility on cards (gradient, top border, badges)
- Bigger action buttons (btn-sm) on machine cards
- White card backgrounds with proper dark mode support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:51:29 +01:00
Matthieu
7e67b124f3 feat(machine) : allow site editing on machine detail page and align card buttons
- Add site select field in MachineInfoCard (edit mode)
- Include siteId in machine PATCH payload
- Align action buttons (Modifier/Supprimer/Détails) consistently at card bottom
- Use mt-auto + flex-col to push buttons to bottom across all machine cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:09:14 +01:00
3ad326348b docs(changelog) : add v1.9.0 release notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:04:25 +01:00
5b9c4ca09d refactor(ui) : improve styling, layout and responsive across all components
Rework CSS theme (app.css), navbar layout, dashboard page, machine detail,
catalog pages, and various form/display components for better consistency
and mobile responsiveness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:14:32 +01:00
6b5eb7bcd6 fix(tests) : fix stale unit tests for useToast and useEntityTypes
useToast.clearAll() now clears the dedup map to prevent test pollution,
and useEntityTypes error test expectation matches actual French message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:39:10 +01:00
98f5d983b3 feat(machine) : add custom field definition editor on machine detail page
Adds UI to create, edit, reorder and delete custom field definitions
directly from the machine detail page in edit mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:34:35 +01:00
cda872a057 fix(config) : disable pathPrefix for component auto-imports 2026-03-08 17:48:11 +01:00
84970a352d refactor(frontend) : extract ProductDocumentsInline to reduce PieceItem under 500 lines
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:30:45 +01:00
c1d14124ff refactor(frontend) : trim product edit page under 500 lines
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:30:41 +01:00
a83a4428c2 refactor(frontend) : extract piece edit page logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:28:04 +01:00
a1998d7966 refactor(frontend) : extract component create page logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:24:50 +01:00
6add558725 refactor(frontend) : extract component edit page logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:23:46 +01:00
e18ce984e7 refactor(frontend) : extract shared piece product selection utils
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:18:55 +01:00
d00e5c058b refactor(frontend) : extract RelatedItemsModal from ManagementView
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:13:27 +01:00
3b24dc128a refactor(frontend) : extract PieceModelStructureEditor logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:09:02 +01:00
c188bd7e8b refactor(frontend) : extract home page modals into components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:14:31 +01:00
e911f169ce refactor(frontend) : extract assignment fetch logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:10:22 +01:00
9f9ad80c61 refactor(frontend) : extract StructureNodeEditor logic into composable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:04:48 +01:00
c831f65ef3 refactor(frontend) : split useMachineDetailData into focused composables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:58:32 +01:00
81eb181000 refactor(frontend) : split componentStructure.ts into focused modules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:43:15 +01:00
a3fde7a191 refactor(frontend) : extract CustomFieldDisplay shared component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:39:54 +01:00
b696b5aa1f refactor(frontend) : extract StructureSkeletonPreview shared component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:35:05 +01:00
c6db96dc76 refactor(frontend) : extract DocumentListInline shared component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:26:14 +01:00
165e0a6341 fix(ui) : prevent dropdown overflow clipping in DataTable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:34:52 +01:00
de7be1b9d0 refactor(frontend) : extract shared components and reduce file sizes
- Extract CustomFieldInputGrid.vue from 6 duplicated template blocks (~70 lines each)
- Extract EntityHistorySection.vue from 3 identical history sections in edit pages
- Extract useDragReorder composable from 4 identical drag-and-drop implementations in StructureNodeEditor (~330 lines → ~30)
- Extract catalogDisplayUtils.ts (resolvePrimaryDocument, resolveSupplierNames, buildSuppliersDisplay)
- Remove redundant computed wrappers (historyEntries, loadingTypes, selectedFiles)
- Remove unused imports (fieldKey, historyActionLabel, formatHistoryDate, *HistoryEntry types)
- Move Intl.DateTimeFormat to module-level in date.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 02:28:26 +01:00
7b3eb1c5fc refactor(catalog) : extract shared delete impact logic and cleanup dead code
Extract duplicated resolveDeleteImpact/buildDeleteMessage into shared utility,
remove redundant computed wrappers, fix indentation, and remove dead code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 01:35:21 +01:00
Matthieu
592beb0fa7 fix(ui) : move add buttons below last element in structure editors
Place "Ajouter" buttons after the items list instead of in the section
header, so they always appear below the last added element.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:28:07 +01:00
Matthieu
e732585e63 fix(catalog) : add delete impact confirmation to product catalog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:06:06 +01:00
Matthieu
f1cc21c31b docs(changelog) : add delete confirmation dialog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:59:43 +01:00
Matthieu
6c2f84dd3a fix(catalog) : replace blocking delete guard with confirmation dialog
Show cascade-delete impact (documents, machine links, custom fields)
in a confirmation modal instead of blocking deletion entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:58:41 +01:00
Matthieu
032b3b33c9 docs(changelog) : add v1.8.1 release notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:39:01 +01:00
Matthieu
32d03b480d refactor(machines) : remove TypeMachine skeleton system, simplify machine creation
- Remove TypeEdit*, TypeInfoDisplay, MachineSkeletonSummary, MachineCreatePreview components
- Remove machine-skeleton pages and type pages
- Remove useMachineTypesApi, useMachineSkeletonEditor, useMachineCreateSelections composables
- Add AddEntityToMachineModal for direct entity linking
- Update machine detail/create pages for direct custom fields
- Fix SearchSelect, category display, and ipartial search filters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:25:23 +01:00
Matthieu
6f1bac381d refacto(tables) : composant DataTable global + migration de toutes les tables
- Nouveau composant DataTable réutilisable avec tri par en-têtes, pagination, filtres colonnes
- Nouveau composable useDataTable (sort/page/search/perPage/columnFilters + persistance URL)
- Migration des 9 tables : constructeurs, comments, admin, pieces-catalog, component-catalog, product-catalog, documents, activity-log, ManagementView (catégories)
- Filtres "Type de" server-side (ipartial) pour pièces, composants, produits

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:05:00 +01:00
Matthieu
89dc2e93b8 docs(readme) : comprehensive project documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:45:32 +01:00
Matthieu
8f5f25b3e7 docs(readme) : replace default Nuxt template with project documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:37:59 +01:00
Matthieu
c06c852493 chore : remove obsolete migration and refactoring docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:08:32 +01:00
Matthieu
41f5319b67 chore(changelog) : add v1.7.0 and v1.8.0 entries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:00:18 +01:00
Matthieu
c7fd8328d6 fix(errors) : humanize backend error messages for end users
Add centralized error translation layer (humanizeError) that converts
raw Symfony/Doctrine/API Platform messages into user-friendly French.
Fix useApi to extract errors from all backend response formats
(violations, error, message, hydra:description, detail).
Add toast deduplication to prevent double display. Replace error toast
icon (X → CircleX) to distinguish from the dismiss button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:48:51 +01:00
Matthieu
55e2a4fafe fix(navbar) : reorder nav groups and add lucide icons
- Reorder: Composants, Pieces, Produits (was Pieces, Produits, Composants)
- Add icons to all nav links and dropdown groups
- Dashboard, Factory, ClipboardList, Cpu, Puzzle, Package, Link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:31:26 +01:00
Matthieu
e88ed5b8f2 feat(documents): migrate storage to filesystem, add server-side pagination
- Replace Base64 data URIs with file-based storage served via dedicated endpoints
- Add DocumentPreviewModal navigation, DocumentThumbnail fileUrl support
- Refactor documents page with server-side pagination, search, sort and filters
- Update all components to use fileUrl/downloadUrl instead of raw path
- Add pagination composable support (total, page, itemsPerPage, attachmentFilter)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:17:59 +01:00
Matthieu
546cc37a09 feat(catalog): add description column with hover popover + skeleton edit guard
- Add description column to pieces and component catalog tables
- Show full text in a popover on hover for truncated descriptions
- Block skeleton editing when machines are linked (warning alert)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:13:06 +01:00
Matthieu
efd0fbe407 feat(catalog) : add description textarea to piece and component forms
Add description field (textarea) between name and reference/fournisseur
on create and edit pages for both pieces and components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:35:52 +01:00
Matthieu
607f84fc3d fix(sites): remove toRefs shadowing causing [object Object] in site name field 2026-03-02 16:33:30 +01:00
Matthieu
a98ab8c275 feat(comments): add comment/ticket system across all entity pages
Add CommentSection component for inline comments on entity detail pages
(machines, pieces, composants, products, categories, skeleton types).
Add dedicated /comments page with filters, pagination and clickable links.
Add unresolved count badge on avatar and in profile dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:06 +01:00
Matthieu
e22463874c fix(constructeurs): improve search filtering and duplicate prevention
Switch ConstructeurSelect to client-side filtering instead of debounced
API calls. Add duplicate name check before creating a new constructeur
in both ConstructeurSelect and the constructeurs page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:05:54 +01:00
Matthieu
256039264e chore: update package-lock.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:02:13 +01:00
Matthieu
e459da7c20 fix(ui) : replace checkbox with toggle switch for boolean custom fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:56:51 +01:00
Matthieu
e84b5cf674 feat(ui) : display role badge in profile dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:42:05 +01:00
Matthieu
cc70fe2b29 feat(permissions) : add role-based UI guards and readonly mode for viewers
- Add usePermissions composable (isAdmin, canEdit, canView)
- Password-protected profile login with modal on profiles page
- Disable all form fields for ROLE_VIEWER across edit/create pages
- Show navigation buttons (Modifier/Consulter) for all roles, hide delete for viewers
- Add readonly prop to ModelTypeForm for category pages
- Disable modal fields (sites, constructeurs) for viewers
- Guard /admin routes in middleware

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:36:42 +01:00
Matthieu
6bed715b7f fix(machines): fix skeleton creation — load all items + atomic creation
- Load composants/pieces/products with itemsPerPage: 200 instead of 30
  (root cause: only first 30 items were available in creation dropdowns)
- Rollback machine if skeleton PATCH fails (delete orphaned machine)
- Initialize custom fields after successful machine creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:39:45 +01:00
Matthieu
dbf8c8856b test(e2e) : add Playwright setup with product and category CRUD specs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:07:23 +01:00
Matthieu
62127a33f5 chore(release) : update changelog for v1.6.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:51:13 +01:00
Matthieu
2fffe4a368 chore(release) : update changelog for v1.6.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:27:43 +01:00
Matthieu
c9054e5b4d feat(categories): add bidirectional piece/component category conversion
Add a "Convertir" button on piece and component category lists that allows
converting an entire category (and all its items) between piece and component.
Includes a modal with eligibility checks and blocker display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:26:41 +01:00
Matthieu
5cab15422d fix(documents) : exclude path from collection to prevent OOM, lazy-load on demand
The path field contains base64 data URIs that can be several MB each.
Loading 200 documents at once exceeded the 128MB PHP memory limit.
Now the collection endpoint uses document:list group (without path)
and the frontend fetches the full document on demand when the user
clicks download or preview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:16:15 +01:00
Matthieu
439db8117a feat(changelog) : add changelog page accessible from footer version link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:01:28 +01:00
Matthieu
675820532c Merge branch 'develop' into master — v1.5.0 2026-02-11 16:50:44 +01:00
Matthieu
4edfc55c37 Merge branch 'fix/filtres-listes' into develop 2026-02-11 16:50:39 +01:00
Matthieu
480aaa24b2 feat(navigation) : preserve list state in URL and use browser history for back buttons
Add useUrlState composable to sync page, search, sort and filter state
with URL query params. Back/forward navigation now restores the exact
list position. Replace hardcoded NuxtLink back buttons with
router.back() across all create/edit pages. Fix documents attachment
filter that checked non-existent ID fields instead of relation objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:48:40 +01:00
Matthieu
185af65519 fix(filters) : repair broken filters on catalog and document pages
- modelTypes.ts: use API Platform OrderFilter format (order[field]=dir) and proper page param
- product-catalog: load all products (itemsPerPage: 200) instead of default 30
- documents: load all documents (itemsPerPage: 200) instead of default 30
- useDocuments: support itemsPerPage option in loadDocuments/loadFromEndpoint
- pieces-catalog + component-catalog: add force:true to bypass stale cache on sort/filter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:32:54 +01:00
Matthieu
8fecf67a7f fix(api): reduce itemsPerPage from 500 to 200 on bulk catalog loads
Prevents memory exhaustion (OOM) on production server when loading
pieces, products, and composants in the component edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:10:52 +01:00
Matthieu
79d2df8bc6 perf(composables) : add smart cache to usePieces and useComposants
Align with useProducts pattern: loaded flag, cache-first return,
loading guard, and clearCache helper to avoid redundant API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:18:42 +01:00
Matthieu
23da4ba4c7 style(theme) : apply Malio brand colors
Primary #304998 (bleu Malio), base #FBFAFA (gris), accent #ED8521
(orange), secondary #A5ACD0 (lavande). Focus ring updated to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:06:20 +01:00
Matthieu
635b8f0461 feat(activity-log) : add global activity log page with filters and pagination
New /activity-log page showing all audit entries across pieces, products
and composants. Includes entity type and action filters, expandable
diffs, clickable entity links and pagination. Navbar link added under
Ressources liées.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:12 +01:00
Matthieu
bf74a50f57 feat(catalog) : make category types clickable in catalog pages
Type columns in piece, component and product catalogs now link
directly to the category edit page for quick access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:07 +01:00
Matthieu
7c44778f25 fix(edit-pages): resolve custom field display race condition
The init watcher destructured currentType/currentStructure before
setting selectedTypeId, so the values were stale (null). This caused
refreshCustomFieldInputs to receive null structure → empty definitions,
permanently wiping custom field display on piece and component edit pages.

Read selectedType.value / selectedTypeStructure.value after setting the
ID so the computed is already updated. Also remove the guard on the
piece selectedType watcher that prevented recovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:47:54 +01:00
Matthieu
9f7dd12b34 perf(edit-pages) : reduce blocking API calls on edit pages
- Remove redundant getCustomFieldValuesByEntity() calls (use entity response)
- Remove redundant refreshDocuments() from onMounted (docs already in entity)
- Make loadHistory() non-blocking (fire-and-forget)
- Defer bulk catalog loads on component edit (pieces/products/composants)
- Use pieceTypes cache instead of separate getModelType() call on piece edit
- Try embedded typeProduct from entity response on product edit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:36 +01:00
Matthieu
67af3c9c46 feat: add API optimizations, cache invalidation and comprehensive test suite
- Add abort controllers and request deduplication to composables
- Add entity type cache invalidation on create/update/delete flows
- Add 179 new tests (utilities, services, composables, components)
- Fix Vue runtime warnings in structure editors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:19:08 +01:00
Matthieu
634184c2be test: configure Vitest and add 54 unit tests (F6.1, F6.2)
Set up Vitest with happy-dom, mock Nuxt auto-imports via #imports alias.
Add tests for: inventory-types validators (9), apiHelpers (10),
modelUtils (18), useConfirm (8), useToast (9). All 54 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:20:28 +01:00
Matthieu
6152848957 feat(ui): replace native confirm() with DaisyUI modal composable (F7.2)
Create useConfirm composable (promise-based, singleton state) and
ConfirmModal component. Replace all 10 confirm()/window.confirm() calls
across 9 pages and 1 composable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:20:13 +01:00
Matthieu
046f464378 refactor(layout): extract AppNavbar component and rewrite app.vue (F7.3)
Extract 680-line navbar into LayoutAppNavbar component with useNavDropdown
composable. app.vue reduced from 698 to 22 LOC.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:20:04 +01:00
Matthieu
8700c253cd chore(lint): enable strict ESLint rules and fix unused-vars violations (F4.1)
Enable no-console (warn, allow error), @typescript-eslint/no-unused-vars
(warn, ignore _ prefix), and @typescript-eslint/no-explicit-any (warn).
Fix all 26 no-unused-vars violations across 9 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:19:56 +01:00
Matthieu
519fa3a8f4 refactor(components): extract shared entity utilities and simplify item components (F1.3, F1.4)
Extract 3 entity composables (useEntityCustomFields, useEntityDocuments,
useEntityProductDisplay) and entityCustomFieldLogic utility shared across
ComponentItem (1336→585 LOC) and PieceItem (1588→740 LOC).
Improve type safety in edit/create pages with explicit casts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:19:40 +01:00
Matthieu
e1594cab76 refactor(machine): decompose create page into composable + 5 components (F1.2)
Extract useMachineCreatePage composable and 5 preview/selector components
from machines/new.vue, reducing it from 1231 to 196 LOC.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:19:29 +01:00
Matthieu
daaa1c4cb9 refactor(machine): decompose detail page into composables + 7 components (F1.1)
Extract 2 composables (useMachineDetailData, useMachineSkeletonEditor) and
7 UI components from machine/[id].vue, reducing it from 2989 to 219 LOC.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:19:22 +01:00
Matthieu
786b1d91f6 refactor(model): split modelUtils.ts into 3 thematic modules (F5.1)
Split 1017 LOC monolith into:
- shared/model/componentStructure.ts (~590 LOC)
- shared/model/pieceProductStructure.ts (~155 LOC)
- shared/model/definitionOverrides.ts (~50 LOC)

Rewrite modelUtils.ts as 37 LOC barrel re-export for backward compat.
All 11 consumer files unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:14:15 +01:00
Matthieu
3436cd0b90 chore: remove 19 debug console.log statements (F4.2)
Remove all console.log/warn/debug/info from production code across 6
files. Keep console.error for legitimate error handling (72 instances).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:14:05 +01:00
Matthieu
efe1fd2a73 refactor(types): eliminate explicit any casts across components (F3.3)
Extend ComponentModelPiece/Product with optional typePiece/typeProduct
nested objects. Replace 12 'as any' casts in assignment node, convert
Promise<any> to Promise<unknown>, use Record<string, unknown> at API
boundaries. ~15 casts eliminated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:13:50 +01:00
Matthieu
a6664ce9a2 refactor(composables): merge 3 type composables into generic (F2.3)
Create useEntityTypes.ts with CRUD + singleton state by category.
Rewrite useComponentTypes, usePieceTypes, useProductTypes as thin
wrappers that rename fields for backward compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:13:39 +01:00
Matthieu
399ec1f7b4 refactor(composables): merge 3 history composables into generic (F2.2)
Create useEntityHistory.ts with parameterized entity type. Rewrite
useComponentHistory, usePieceHistory, useProductHistory as thin
backward-compatible wrappers (67→13 LOC each).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:13:31 +01:00
Matthieu
86bb8af32d refactor(api): extract shared extractCollection helper (F2.1)
Create shared/utils/apiHelpers.ts with generic extractCollection<T>()
that handles hydra:member, member, items, data, and array formats.
Replace 7 local implementations in CRUD composables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:13:20 +01:00
Matthieu
78718b85ae refactor(composables): migrate JS composables to TypeScript (F3.2)
Convert 7 composables from JS to TS with proper type annotations:
useApi, useCustomFields, useProfileSession, useProfiles, useToast,
useMachineTypesApi, useMachines. Remove deprecated stubs
useComponentModels.js and usePieceModels.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:13:09 +01:00
Matthieu
9ee348fff0 refactor(front): extract shared utils and rewire pages 2026-02-06 17:16:16 +01:00
Matthieu
1fbd1d1b2e refacto(F1.2) : extract modules from machines/new.vue (2313→1231 LOC)
Extract assignment normalization utils to shared/utils/assignmentUtils.ts.
Extract selection state management to composables/useMachineCreateSelections.ts.
Extract preview computation and validation to composables/useMachineCreatePreview.ts.
Wire machines/new.vue to use extracted modules (-47% LOC).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 09:15:22 +01:00
Matthieu
1f2d6c78e8 refacto(F1.1) : wire [id].vue to use extracted modules and fix TS errors
Wire machine/[id].vue to import from extracted utility modules
(customFieldUtils, productDisplayUtils, useMachineHierarchy, useMachinePrint).
Remove ~1400 LOC of inline functions replaced by imports.
Fix TypeScript errors in extracted composables (AnyRecord/ConstructeurSummary
boundary casts, Map generics, optional chaining on unknown).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 08:58:42 +01:00
Matthieu
649f8ca9cc refacto(F1.1): extract utility modules from machine/[id].vue
Extract ~1300 LOC of reusable logic into dedicated modules:
- shared/utils/customFieldUtils.ts: field normalization, merge, dedup, display
- shared/utils/productDisplayUtils.ts: product resolution and display helpers
- composables/useMachineHierarchy.ts: hierarchy tree builder from links
- composables/useMachinePrint.ts: print selection and execution logic

These extractions prepare the ground for wiring [id].vue to import
from these modules instead of inlining all logic.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:34:33 +01:00
3705b8daed feat(model-types): allow adding custom fields in restricted mode
When a category has linked items (pieces, components, products),
enable restricted mode instead of blocking all edits:
- Allow adding new custom fields
- Lock existing fields from modification or deletion
- Hide add buttons for products, pieces, and subcomponents
- Display informative message about restricted mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 19:53:56 +01:00
Matthieu
202b964b24 chore(branding): update navbar logo and app name 2026-01-25 22:31:40 +01:00
Matthieu
a1d15c23a4 feat(history): add audit history views for products, pieces and components 2026-01-25 21:20:14 +01:00
Matthieu
a7101c7e77 feat(model-types): add related-items modal and guard category edits 2026-01-25 20:29:28 +01:00
Matthieu
adccfa9b46 fix : use name parameter for API search filter 2026-01-25 15:53:57 +01:00
Matthieu
5f54acdfac chore : merge migration-to-symfony into master for v1.0.0 2026-01-25 12:06:59 +01:00
Matthieu
94239031d6 feat: add version system from parent VERSION file 2026-01-25 12:01:26 +01:00
Matthieu
b27662d2bc Show component selections and support multi product requirements 2026-01-25 11:40:29 +01:00
Matthieu
55739fe50f Fix machines display on overview; disable inline PDF thumbnails 2026-01-25 09:46:11 +01:00
Matthieu
1f5f1509a9 wip: machine create skeleton links 2026-01-24 00:58:06 +01:00
Matthieu
a8cb4d1ac0 wip: dynamic search for component create 2026-01-23 23:29:40 +01:00
8af8374282 feat(ui): ajoute la pagination et la recherche serveur 2026-01-23 19:35:00 +01:00
9cc7ac10f0 WIP: corrections multiples formulaires et sérialisation
- Fix constructeurUtils: réordonner delete/add pour sauvegarder les fournisseurs
- Fix prix/supplierPrice: envoyer en string pour DECIMAL Doctrine
- Fix useMachineTypesApi: normaliser les requirements et forceRefresh
- Fix SearchSelect: watch deep sur baseOptions
- Debug logs temporaires pour pieceRequirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:28:40 +01:00
Matthieu
86d15faa01 fix: add missing template tag and preserve constructeurIds
- Fix missing <template> tag in product/create.vue causing build error
- Preserve constructeurIds when product already has constructeurs loaded

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 11:48:56 +01:00
Matthieu
603c03ca00 fix(frontend): handle supplier price parsing in edit 2026-01-21 18:11:09 +01:00
Matthieu
155cd9b358 fix(frontend): handle supplier price parsing 2026-01-21 18:02:52 +01:00
2f3d4c5260 chore(dev): exposer le serveur nuxt 2026-01-15 13:43:28 +01:00
51edd7f655 fix(machines): enrichir les relations 2026-01-15 13:43:23 +01:00
2e4d61c3ea fix(modeles): normaliser structure et champs perso 2026-01-15 13:43:18 +01:00
52f75c5301 fix(modeles): paginer apres filtre categorie 2026-01-15 12:51:30 +01:00
84048bf3a2 fix(modeles): filtrer par categorie 2026-01-14 23:10:42 +01:00
0bfb69ad13 fix(fournisseurs): résoudre les IRIs 2026-01-14 23:10:34 +01:00
ddce3ff3ae feat(tri): mémoriser les préférences de tri 2026-01-14 23:10:27 +01:00
b5af7f13b6 wip(frontend) : api calls + skeleton fetch 2026-01-12 13:14:12 +01:00
e99f053233 feat(front): aligner api platform et sessions [INV-20260111-02] 2026-01-11 17:14:24 +01:00
Matthieu
936a73fde3 Fix fournisseur handling across catalog flows 2025-12-03 11:29:11 +01:00
Matthieu
34af59d054 feat: show product thumbnails in catalogue list
Display the primary product document (image/pdf) as the leading column in the catalogue table for quicker visual identification.
2025-11-05 15:38:44 +01:00
Matthieu
d860f24e69 feat: add product catalogue and product-aware UI
- introduce product catalogue pages, management view entries and shared product composables\n- wire product selection into component/piece flows and machine skeleton requirements\n- display linked product metadata and documents across machine, component and piece views\n- generalize model type tooling to handle PRODUCT category
2025-11-05 15:35:02 +01:00
Matthieu
3af6c50892 feat: retire la colonne catégorie des catalogues 2025-10-31 10:04:40 +01:00
Matthieu
dc2bc6c70a feat: afficher fournisseur dans les libellés front 2025-10-31 10:02:27 +01:00
Matthieu
ef9a8b5b7b fix: format plain french numbers with dot grouping 2025-10-30 17:35:44 +01:00
Matthieu
53dab13489 feat: standardize contact formatting 2025-10-30 11:35:20 +01:00
Matthieu
f59255e684 fix: de-duplicate constructeur ids before machine update 2025-10-30 11:34:58 +01:00
Matthieu
76cd3fac98 feat: improve piece structure editor UX 2025-10-30 11:34:19 +01:00
Matthieu
4c714b3647 feat: drag & drop des champs personnalisés 2025-10-28 18:08:14 +01:00
Matthieu
b752fba69a feat: gérer les constructeurs multiples 2025-10-28 16:37:10 +01:00
Matthieu
da447e4ea2 feat: supprimer automatiquement les valeurs de champs si besoin 2025-10-24 15:50:20 +02:00
Matthieu
5684bc282b fix: afficher le détail des blocages avant suppression 2025-10-24 15:42:22 +02:00
Matthieu
e9c7a3d1a7 feat: ajouter les miniatures de documents dans les catalogues 2025-10-24 15:25:40 +02:00
Matthieu
d011e58030 feat: ajouter le tri par nom sur les catalogues 2025-10-24 15:18:24 +02:00
Matthieu
325bdb3d6f feat: enable drag reorder for skeleton requirements 2025-10-23 09:36:46 +02:00
Matthieu
417b34b45e feat: enrich piece assignment labels and document previews 2025-10-23 09:01:38 +02:00
Matthieu
553600c34b Add searchbar in catalogue and update custom fuild bug 2025-10-17 10:10:52 +02:00
Matthieu
42c788103a add sub componet in catego ske 2025-10-16 16:48:36 +02:00
Matthieu
761c5f559a fix dropdwon blank space bug 2025-10-16 14:18:33 +02:00
Matthieu
4ccc19505f add img preview + fix navbar 2025-10-16 10:26:36 +02:00
Matthieu
8eada12438 feat: add file upload on componet and delete code champs 2025-10-16 10:05:32 +02:00
Matthieu
ebc02f41d9 Add new dropdown search 2025-10-16 09:11:26 +02:00
Matthieu
62b5c9b297 Add new dropdown search 2025-10-16 08:51:18 +02:00
Matthieu
e297d1bb39 feat(frontend): add reusable search select and wire it into machine creation
fix(frontend): guard custom field persistence against non-string values
2025-10-13 17:03:06 +02:00
Matthieu
469bcb82d1 update ui machine view 2025-10-13 10:41:31 +02:00
Matthieu
06ae0ca7aa feat: improve machine component hierarchy handling 2025-10-13 09:01:19 +02:00
MatthieuTD
95c2a82689 Normalize machine link responses 2025-10-09 09:21:40 +02:00
Matthieu
f89364d04e feat: reuse inventory items when configuring machines 2025-10-08 16:29:00 +02:00
MatthieuTD
bc8823a776 Allow component catalog to instantiate components without machine 2025-10-07 08:30:49 +02:00
MatthieuTD
14e8faf3a1 Move category editor to full pages and simplify root skeleton 2025-10-07 08:30:40 +02:00
MatthieuTD
c5cd75a19f Restore catalog links in navigation 2025-10-06 17:27:46 +02:00
MatthieuTD
f9641dbd62 Restore catalogs and type-aware machine selection 2025-10-06 17:19:30 +02:00
MatthieuTD
082b1ccc05 chore: provide deprecated model composables 2025-10-06 17:02:37 +02:00
MatthieuTD
384c3f0680 Restore component catalog with requirement-based instantiation 2025-10-06 16:55:45 +02:00
MatthieuTD
c5f2c568b6 chore: retire legacy model catalogs 2025-10-02 16:29:50 +02:00
MatthieuTD
386a1c9d1b refactor: adopt canonical component model structure schema 2025-10-01 14:26:31 +02:00
Matthieu
d3f8ac3649 chore(front): expose app version in runtime config 2025-10-01 09:49:48 +02:00
Matthieu
a2b2151222 chore(front): display app version from env 2025-10-01 08:55:10 +02:00
Matthieu
7f19d9ba4e feat(front): allow recursive skeleton selection for machines 2025-10-01 08:36:00 +02:00
Matthieu
25d2aa1bcc feat(models): align component model editing with type selection~ 2025-09-30 17:32:00 +02:00
Matthieu
84bc99d8ec feat(ui): streamline skeleton model selection and toast display 2025-09-30 16:51:22 +02:00
Matthieu
7b2e509b04 feat(ui): compact toast notifications and skeleton editor selection 2025-09-30 15:57:02 +02:00
MatthieuTD
9a55e29b74 Fix prop defaults without withDefaults 2025-09-30 11:16:55 +02:00
MatthieuTD
fcab426f8a Fix JS syntax by removing TS annotations in machine page 2025-09-30 11:11:25 +02:00
MatthieuTD
1d1e0b7bd0 feat(machine): allow configuring nested skeleton models 2025-09-30 11:07:24 +02:00
Matthieu
fd60cbbbfe fix champs personnalisé update 2025-09-30 10:46:46 +02:00
Matthieu
c489f093ed fix: Mise a jour ui simple
- supp champs emplacement
- augmenter espace en label et input
sup btn compléter champs perso
2025-09-29 15:36:14 +02:00
Matthieu
43b615ac3e feat: reorganize machine skeleton pages 2025-09-29 15:05:54 +02:00
Matthieu
a78938a4d1 chore: update frontend configuration 2025-09-26 11:29:47 +02:00
Matthieu
b7caa4f552 Rename dashboard 2025-09-25 16:22:14 +02:00
Matthieu
d1ce074c6d frontend: remove legacy model-types page 2025-09-25 16:15:27 +02:00
Matthieu
cab9b216e6 frontend: improve navbar dropdown behaviour and navigation 2025-09-25 16:14:41 +02:00
Matthieu
8e3894bfe2 frontend: refactor model type management and catalog routes 2025-09-25 16:14:22 +02:00
Matthieu
801fe5be95 frontend: add machine skeleton page 2025-09-25 16:13:55 +02:00
Matthieu
0d2748f660 frontend: fix create-site form refs 2025-09-25 16:13:07 +02:00
MatthieuTD
e25e8c2669 Reset new site form on modal open and close 2025-09-25 15:14:00 +02:00
MatthieuTD
f9de94907b Fix site contact form field bindings 2025-09-25 14:57:29 +02:00
MatthieuTD
041478e9d4 Add shared form fields for contact details 2025-09-25 14:44:42 +02:00
MatthieuTD
a4840c454f Refactor duplicated site forms and requirements 2025-09-25 12:01:28 +02:00
MatthieuTD
ac0687ac8f refactor: extract site management flow 2025-09-25 11:45:58 +02:00
Matthieu
7980aa186b FIx: delete champs par default 2025-09-25 11:25:43 +02:00
Matthieu
bdae2621c5 feat(frontend): améliorer éditeurs de structure 2025-09-24 09:40:43 +02:00
Matthieu
f924c65ab8 feat(frontend): harmoniser navigation et libellés 2025-09-24 09:39:15 +02:00
Matthieu
83b3e33b1e feat: Add model feature for piece and component 2025-09-23 15:06:19 +02:00
MatthieuTD
c1e170b088 Merge pull request #4 from MatthieuTD/codex/add-custom-fields-management-feature
Add automatic completion of missing custom fields on machine page
2025-09-22 11:12:12 +02:00
MatthieuTD
5501b3b5ef feat: auto complete missing custom fields on machine page 2025-09-22 11:12:00 +02:00
MatthieuTD
ae103a38be Merge pull request #3 from MatthieuTD/codex/add-reconfigureskeleton-method-in-usemachines
feat: allow machine skeleton reconfiguration
2025-09-22 10:57:33 +02:00
MatthieuTD
57a08bb8c9 feat: allow machine skeleton reconfiguration 2025-09-22 10:52:44 +02:00
MatthieuTD
ee659c4e16 Merge pull request #2 from MatthieuTD/codex/create-new-machine-page-with-form
feat: move machine creation to dedicated page
2025-09-22 10:34:49 +02:00
MatthieuTD
9095cfd054 feat: move machine creation to dedicated page 2025-09-22 10:34:31 +02:00
Matthieu
936a9d74ca set up new view for skeleton hiearchi 2025-09-22 08:34:05 +02:00
Matthieu
e33e91ee26 fix(machine-ui): restore machine detail layout and align api base url 2025-09-19 15:00:56 +02:00
Matthieu
b0c3b2b646 chore(frontend): clean remaining templates and configs 2025-09-19 08:19:45 +02:00
Matthieu
32dd8fab58 feat(frontend): replace inline icons with lucide components 2025-09-19 08:19:09 +02:00
Matthieu
dec4d451bb fix: avoid nested forms in type generator pages 2025-09-18 11:57:36 +02:00
Matthieu
367e356765 fix: prevent recursive form sync in type editor 2025-09-18 11:52:19 +02:00
Matthieu
b568b22461 fix: keep session middleware from redirecting prematurely 2025-09-18 10:12:41 +02:00
Matthieu
94f296c64b fix: center profile initials in avatar 2025-09-18 09:06:29 +02:00
Matthieu
42f0f939e8 chore: enable vscode format on save 2025-09-18 08:59:51 +02:00
Matthieu
87cd5e8b2a refactor: remove unused page hero components 2025-09-18 08:54:33 +02:00
Matthieu
a8fab0d718 fix: center initials in profile avatar 2025-09-18 08:50:52 +02:00
Matthieu
45356ec3ae fix: limit profile redirect middleware to client 2025-09-18 08:44:38 +02:00
Matthieu
ac948bbf5e fix: ensure profile middleware respects full path 2025-09-18 08:40:09 +02:00
Matthieu
4787c1ea8f fix: normalize route path in profile middleware 2025-09-18 08:35:18 +02:00
Matthieu
95c1e66520 fix: guard profile middleware on profiles routes 2025-09-18 08:30:29 +02:00
Matthieu
316dcb6339 feat: add profile management flow 2025-09-17 23:11:13 +02:00
Matthieu
37c66ac3d6 feat: add document preview overlay 2025-09-17 17:12:41 +02:00
Matthieu
8a32ef4bbc fix: stabilize constructeur selector ui 2025-09-17 16:21:42 +02:00
Matthieu
0a95b90553 feat: add constructors selection and management 2025-09-17 15:10:01 +02:00
Matthieu
3c0c22ad0f feat: enhance document management UI 2025-09-17 12:41:51 +02:00
Matthieu
0fbf77ab43 feat(site): allow document management 2025-09-17 11:40:50 +02:00
Matthieu
c63b543c74 feat: improve site management and type forms 2025-09-17 11:13:57 +02:00
Matthieu
dd0ef12b46 refactor: rename prestataire to constructeur 2025-09-17 08:50:03 +02:00
Matthieu
d605f2418f feat: refactor type management screens 2025-09-17 08:18:28 +02:00
Matthieu
4d2d35f360 feat: use runtime config for public env 2025-09-17 08:16:27 +02:00
Matthieu
95da7c72db feat: replis hiérarchie composants et sections type 2025-09-16 17:14:42 +02:00
Matthieu
c33a04b68e feat: ajout du formulaire de modification complète des types de machines
- Création du composant TypeEditForm.vue pour l'édition complète des types
- Ajout de la page /type/edit/[id].vue pour l'édition complète
- Ajout des boutons d'édition dans les pages types.vue et type/[id].vue
- Gestion complète des champs personnalisés, pièces et composants
- Interface intuitive avec ajout/suppression dynamique d'éléments
2025-07-31 17:42:28 +02:00
Matthieu
74b78137a0 feat: mise à jour package.json frontend avec script start pour production 2025-07-30 09:59:05 +02:00
Matthieu
36a44848d2 feat: Composants d'affichage des machines et composants
- ComponentHierarchy.vue : Affichage hiérarchique des composants
- ComponentItem.vue : Affichage d'un composant individuel
- CustomFieldsDisplay.vue : Affichage des champs personnalisés
- PieceItem.vue : Affichage des pièces de machines
- Support de l'affichage en lecture seule et édition
- Gestion des relations parent-enfant entre composants
2025-07-30 08:18:30 +02:00
Matthieu
d99768bc94 feat: Pages de détail et édition
- type/[id].vue : Page de détail et édition des types de machines
- machine/[id].vue : Page de détail et édition des machines
- Support du mode édition avec paramètre URL ?edit=true
- Gestion des composants hiérarchiques et champs personnalisés
- Interface d'édition en temps réel avec sauvegarde
2025-07-30 08:18:20 +02:00
Matthieu
8965ee97a3 feat: Pages principales de l'application
- index.vue : Dashboard avec vue hiérarchique des sites et machines
- machines.vue : Liste des machines avec filtres et actions
- sites.vue : Gestion des sites industriels
- types.vue : Gestion des types de machines
- generator.vue : Générateur de types de machines
- Système de navigation et gestion d'état
2025-07-30 08:18:11 +02:00
Matthieu
23e2397de6 feat: Système de paramètres d'affichage et accessibilité
- DisplaySettings.vue : Modal pour configurer l'affichage
- Contrôles de zoom (80% à 150%)
- Densité de l'interface (compacte, confortable, espacée)
- Contraste (normal, élevé)
- Persistance des paramètres via localStorage
- Protection du modal contre les transformations de zoom
2025-07-30 08:17:44 +02:00
Matthieu
ca44a78aad feat: Composants de formulaire pour les types de machines
- TypeComponentForm.vue : Formulaire pour ajouter des composants avec hiérarchie
- TypeMachinePieceForm.vue : Formulaire pour ajouter des pièces principales
- Support des champs personnalisés avec différents types (text, number, select, boolean, date)
- Gestion des sous-composants et pièces imbriquées
- Validation et gestion des options pour les champs de type SELECT
2025-07-30 08:17:33 +02:00
Matthieu
9fb0353442 feat: Composants d'affichage des types de machines
- TypeComponentDisplay.vue : Affichage des composants existants avec hiérarchie
- TypeInfoDisplay.vue : Informations générales du type de machine
- TypeMachinePieceDisplay.vue : Affichage des pièces principales
- Support de l'affichage en lecture seule des structures complexes
- Gestion des champs personnalisés et sous-composants
2025-07-30 08:17:01 +02:00
Matthieu
36b7ce93ec feat: Layout principal et composants de base
- app.vue : Layout principal avec navbar et navigation
- ToastContainer.vue : Système de notifications toast
- app.css : Styles globaux avec DaisyUI et accessibilité
- Configuration des paramètres d'affichage (zoom, densité, contraste)
- Navigation responsive avec menu déroulant
- Bouton 'Nouveau' avec actions rapides
2025-07-30 08:16:33 +02:00
Matthieu
1ca5c347b7 feat: Composables pour la gestion des données
- useApi.js : Service API générique avec gestion d'erreurs
- useSites.js : Gestion des sites industriels
- useMachines.js : Gestion des machines et création depuis types
- useMachineTypes.js : Gestion des types de machines
- useMachineTypesApi.js : API pour les types de machines
- useComposants.js : Gestion des composants hiérarchiques
- usePieces.js : Gestion des pièces de machines
- useCustomFields.js : Gestion des champs personnalisés
- useToast.js : Système de notifications toast
2025-07-30 08:15:35 +02:00
Matthieu
7613374e1f feat: Configuration de base du projet Nuxt.js avec DaisyUI
- Initialisation du projet Nuxt.js
- Configuration de DaisyUI pour le design system
- Fichiers de configuration (package.json, nuxt.config.ts, tsconfig.json)
- Structure des dossiers publics et assets
- Documentation README et variables d'environnement
2025-07-30 08:15:21 +02:00
309 changed files with 61629 additions and 530 deletions

View File

@@ -2,13 +2,13 @@
.gitea .gitea
.env.local .env.local
.env.test .env.test
docker/ infra/dev/
deploy/docker/docker-compose.prod.yml infra/prod/docker-compose.yml
deploy/docker/deploy.sh infra/prod/deploy.sh.example
deploy/docker/.env.example infra/prod/.env.example
Inventory_frontend/node_modules frontend/node_modules
Inventory_frontend/.nuxt frontend/.nuxt
Inventory_frontend/.output frontend/.output
var/ var/
vendor/ vendor/
LOG/ LOG/
@@ -21,4 +21,4 @@ scripts/
*.md *.md
!composer.lock !composer.lock
!symfony.lock !symfony.lock
!Inventory_frontend/package-lock.json !frontend/package-lock.json

View File

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

View File

@@ -11,8 +11,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: true
- name: Login to Gitea Registry - name: Login to Gitea Registry
run: | run: |
@@ -21,7 +19,7 @@ jobs:
- name: Build Docker image - name: Build Docker image
run: | run: |
docker build \ docker build \
-f deploy/docker/Dockerfile.prod \ -f infra/prod/Dockerfile \
-t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \ -t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/inventory:latest \ -t gitea.malio.fr/malio-dev/inventory:latest \
. .

6
.gitignore vendored
View File

@@ -20,7 +20,7 @@
###< phpunit/phpunit ### ###< phpunit/phpunit ###
###> docker ### ###> docker ###
docker/.env.docker.local infra/dev/.env.docker.local
###< docker ### ###< docker ###
###> migration archives ### ###> migration archives ###
/_archives/ /_archives/
@@ -34,10 +34,6 @@ FEATURE_IDEAS.md
bin/.phpunit.result.cache bin/.phpunit.result.cache
###< temp files ### ###< temp files ###
###> frontend ###
/frontend/
###< frontend ###
###> ide ### ###> ide ###
/.idea/ /.idea/
###< ide ### ###< ide ###

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "Inventory_frontend"]
path = Inventory_frontend
url = gitea@gitea.malio.fr:MALIO-DEV/Inventory_frontend.git

View File

@@ -43,7 +43,7 @@ Inventory/ # Backend Symfony (repo principal)
├── pre-commit, commit-msg # Git hooks ├── pre-commit, commit-msg # Git hooks
├── makefile # Commandes Docker/dev ├── makefile # Commandes Docker/dev
├── VERSION # Source unique de version (semver) ├── VERSION # Source unique de version (semver)
├── Inventory_frontend/ # ← SUBMODULE GIT (repo séparé) ├── frontend/ # ← SUBMODULE GIT (repo séparé)
│ ├── app/pages/ # Pages Nuxt (file-based routing) │ ├── app/pages/ # Pages Nuxt (file-based routing)
│ ├── app/components/ # Composants Vue (auto-imported) │ ├── app/components/ # Composants Vue (auto-imported)
│ ├── app/composables/ # Composables Vue │ ├── app/composables/ # Composables Vue
@@ -67,7 +67,7 @@ make test FILES=tests/Api/Entity/MachineTest.php # Un test spécifique
make php-cs-fixer-allow-risky # Linter PHP (cs-fixer) make php-cs-fixer-allow-risky # Linter PHP (cs-fixer)
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
# Frontend (dans Inventory_frontend/) # Frontend (dans frontend/)
npm run dev # Dev server (port 3001) npm run dev # Dev server (port 3001)
npm run build # Build production npm run build # Build production
npm run lint:fix # ESLint fix npm run lint:fix # ESLint fix
@@ -109,7 +109,7 @@ Exemples :
### Submodule Workflow ### Submodule Workflow
Le frontend est un submodule git. Lors d'un commit frontend : Le frontend est un submodule git. Lors d'un commit frontend :
1. Commit dans `Inventory_frontend/` d'abord 1. Commit dans `frontend/` d'abord
2. Commit dans le repo principal pour mettre à jour le pointeur submodule 2. Commit dans le repo principal pour mettre à jour le pointeur submodule
3. Push les deux repos 3. Push les deux repos

View File

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

View File

@@ -57,7 +57,7 @@ make start
make install make install
``` ```
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`. > Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `infra/dev/.env.docker.local`.
### Que fait `make install` ? ### Que fait `make install` ?
@@ -166,7 +166,7 @@ Inventory/ # Backend Symfony (repo principal)
├── docker/ # Dockerfile + config Docker ├── docker/ # Dockerfile + config Docker
├── makefile # Commandes de dev raccourcies ├── makefile # Commandes de dev raccourcies
├── VERSION # Version courante (ex: 1.8.1) ├── VERSION # Version courante (ex: 1.8.1)
└── Inventory_frontend/ # Submodule git (frontend, repo séparé) └── frontend/ # Submodule git (frontend, repo séparé)
├── app/pages/ # Les pages de l'app (1 fichier = 1 route URL) ├── app/pages/ # Les pages de l'app (1 fichier = 1 route URL)
├── app/components/ # Composants Vue réutilisables ├── app/components/ # Composants Vue réutilisables
├── app/composables/ # Logique métier partagée (appels API, états) ├── app/composables/ # Logique métier partagée (appels API, états)
@@ -254,7 +254,7 @@ Configuration PhpStorm / VSCode :
- **Port** : `8081` - **Port** : `8081`
- **Path mapping** : racine du projet → `/var/www/html` - **Path mapping** : racine du projet → `/var/www/html`
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `docker/.env.docker.local` avec votre IP locale. > Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `infra/dev/.env.docker.local` avec votre IP locale.
## Git ## Git
@@ -289,9 +289,9 @@ Le hook `pre-commit` s'exécute automatiquement avant chaque commit :
### Submodule frontend ### Submodule frontend
Le frontend est un **submodule git** dans `Inventory_frontend/` (c'est un repo git séparé, inclus dans le repo principal). Workflow de commit : Le frontend est un **submodule git** dans `frontend/` (c'est un repo git séparé, inclus dans le repo principal). Workflow de commit :
1. Commiter dans `Inventory_frontend/` d'abord 1. Commiter dans `frontend/` d'abord
2. Commiter dans le repo principal pour mettre à jour le pointeur du submodule 2. Commiter dans le repo principal pour mettre à jour le pointeur du submodule
3. Pousser les deux repos 3. Pousser les deux repos
@@ -302,4 +302,4 @@ Le frontend est un **submodule git** dans `Inventory_frontend/` (c'est un repo g
- **[DEPLOY.md](DEPLOY.md)** : guide de déploiement serveur (Nginx, PHP-FPM, PostgreSQL) - **[DEPLOY.md](DEPLOY.md)** : guide de déploiement serveur (Nginx, PHP-FPM, PostgreSQL)
- **[RELEASE.md](RELEASE.md)** : processus de release et versioning - **[RELEASE.md](RELEASE.md)** : processus de release et versioning
- **[CHANGELOG.md](CHANGELOG.md)** : historique des versions - **[CHANGELOG.md](CHANGELOG.md)** : historique des versions
- **[Frontend README](Inventory_frontend/README.md)** : documentation du frontend Nuxt - **[Frontend README](frontend/README.md)** : documentation du frontend Nuxt

View File

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

View File

@@ -1 +0,0 @@
1.9.4

View File

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

View File

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

View File

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

2
config/version.yaml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

29
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# Playwright
e2e/.auth/
playwright-report/
test-results/

155
frontend/README.md Normal file
View File

@@ -0,0 +1,155 @@
# Inventory Frontend
Interface web de gestion d'inventaire industriel pour **Malio**. Application SPA complète permettant la gestion du parc machines, des pièces, composants, produits, fournisseurs et documents associés.
## Stack technique
| Technologie | Version | Rôle |
|-------------|---------|------|
| [Nuxt](https://nuxt.com) | 4 | Framework (SPA, SSR désactivé) |
| [Vue 3](https://vuejs.org) | 3.5 | Composition API + `<script setup>` |
| [TypeScript](https://www.typescriptlang.org) | 5.7 | Typage strict sur l'ensemble du projet |
| [TailwindCSS](https://tailwindcss.com) | 4 | Utility-first CSS |
| [DaisyUI](https://daisyui.com) | 5 | Composants UI (alertes, modales, badges, etc.) |
| [Lucide](https://lucide.dev) | via unplugin-icons | Icônes SVG |
| [Vitest](https://vitest.dev) | 4 | Tests unitaires |
| [Playwright](https://playwright.dev) | 1.58 | Tests E2E |
## Prérequis
- **Node.js** >= 20
- **npm**
- **Backend Symfony** démarré avec l'API sur `http://localhost:8081/api`
## Installation
```bash
npm install
```
## Développement
```bash
npm run dev
```
L'application est accessible sur **http://localhost:3001**.
## Commandes disponibles
| Commande | Description |
|----------|-------------|
| `npm run dev` | Serveur de développement avec HMR |
| `npm run build` | Build de production |
| `npm run lint:fix` | Correction automatique ESLint |
| `npx nuxi typecheck` | Vérification TypeScript (0 erreurs attendu) |
| `npm run test` | Tests unitaires Vitest |
| `npm run test:watch` | Tests unitaires en mode watch |
| `npm run test:e2e` | Tests E2E Playwright (Chrome) |
## Fonctionnalités
### Gestion du parc
- **Machines** : création, édition, vue détaillée avec structure hiérarchique (composants, pièces, produits)
- **Squelettes machines** : templates réutilisables pour créer des machines à partir d'un modèle type
- **Sites** : gestion multi-sites avec coordonnées de contact
### Catalogues
- **Composants**, **Pièces**, **Produits** : catalogues avec recherche serveur, tri, pagination et filtres
- **Catégories** : système de types avec champs personnalisés configurables et exigences (contraintes de structure)
- **Fournisseurs** : gestion des constructeurs/fabricants avec liaison multi-entités
### Documents et traçabilité
- **Documents** : upload, prévisualisation PDF/images, stockage sur système de fichiers avec compression PDF automatique
- **Journal d'activité** : audit trail complet sur toutes les entités (création, modification, suppression)
- **Commentaires** : système de tickets/commentaires sur les fiches avec statut ouvert/résolu
### Administration
- **Rôles** : ADMIN, GESTIONNAIRE, VIEWER avec permissions granulaires
- **Profils** : gestion des utilisateurs et attribution des rôles
- **Notifications** : badge compteur de commentaires ouverts avec polling
## Architecture
```
app/
├── pages/ # 36 pages (file-based routing)
├── components/ # 57 composants Vue (auto-imported par Nuxt)
│ ├── common/ # Composants UI réutilisables (modales, pagination, recherche)
│ ├── form/ # Champs de formulaire (email, téléphone)
│ ├── layout/ # Navbar principale
│ ├── machine/ # Vue détail et création de machines
│ │ └── create/ # Wizard de création machine
│ ├── model-types/ # Gestion des types/catégories
│ └── sites/ # Modales site (création, édition)
├── composables/ # 45 composables (logique métier)
├── shared/ # Types, utilitaires, validation
│ ├── utils/ # Helpers API, champs personnalisés, affichage, erreurs
│ ├── validation/ # Validation email, téléphone
│ └── model/ # Définitions de structures
├── services/ # Service layer (wrappers API spécialisés)
├── middleware/ # Middleware d'auth global (session cookie)
└── utils/ # Formatage dates, montants, événements
```
## Conventions de code
### Composables
Pattern avec injection de dépendances explicite :
```typescript
interface Deps {
machineId: Ref<string>
onSave: () => void
}
export function useMachineDetail(deps: Deps) {
// ...
}
```
### Communication entre composants
**Props + Events uniquement** — pas de `provide/inject` dans le projet.
### Appels API
Le composable `useApi.ts` centralise tous les appels HTTP :
- Cookies de session inclus automatiquement (`credentials: 'include'`)
- `application/ld+json` pour POST/PUT
- `application/merge-patch+json` pour PATCH
- Gestion d'erreurs centralisée avec traduction des messages backend en français
### Styles
Classes DaisyUI standard :
- Input : `input input-bordered input-sm md:input-md`
- Select : `select select-bordered select-sm md:select-md`
- Button : `btn btn-sm md:btn-md btn-primary`
## Authentification
L'application utilise une **authentification par session (cookies)**, pas de JWT.
Le middleware global `profile.global.ts` vérifie la session à chaque navigation :
- Utilisateur non connecté → redirection vers `/profiles`
- Route `/admin/*` → accès restreint à `ROLE_ADMIN`
## Tests
- **13 tests unitaires** (Vitest + happy-dom) couvrant composables, utils et composants
- **3 specs E2E** (Playwright + Chrome) avec setup d'authentification
## Submodule Git
Ce repo est un **submodule** du repo principal [Inventory](https://gitea.malio.fr/MALIO-DEV/Inventory).
Workflow de commit :
1. Commiter dans ce repo (frontend) en premier
2. Commiter dans le repo principal pour mettre à jour le pointeur submodule
3. Pousser les deux repos

64
frontend/app/app.vue Normal file
View File

@@ -0,0 +1,64 @@
<template>
<div class="min-h-screen flex flex-col bg-base-200/40">
<!-- Subtle dot pattern background -->
<div class="fixed inset-0 -z-10 bg-[radial-gradient(oklch(85%_0.02_260)_1px,transparent_1px)] bg-[size:24px_24px] opacity-40" />
<AppNavbar
@open-settings="displaySettingsOpen = true"
@logout="handleLogout"
/>
<AppBreadcrumb />
<main class="flex-1">
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />
</main>
<ToastContainer />
<ConfirmModal />
<DisplaySettings
:is-open="displaySettingsOpen"
@close="displaySettingsOpen = false"
@update-settings="handleSettingsUpdate"
/>
<footer class="border-t border-base-300/50 bg-base-100/60 backdrop-blur-sm">
<div class="container mx-auto flex items-center justify-between px-6 py-3">
<p class="text-xs text-base-content/40 font-medium tracking-wide">
&copy; Malio {{ new Date().getFullYear() }}
</p>
<NuxtLink
to="/changelog"
class="text-xs text-base-content/40 hover:text-primary transition-colors font-medium"
>
v{{ appVersion }}
</NuxtLink>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { navigateTo, useRuntimeConfig } from '#imports'
import { useProfileSession } from '~/composables/useProfileSession'
const displaySettingsOpen = ref(false)
const { ensureSession, logout } = useProfileSession()
const runtimeConfig = useRuntimeConfig()
const appVersion = computed(() => (runtimeConfig.public?.appVersion as string) ?? '0.1.0')
const handleSettingsUpdate = (_settings: unknown) => {
// Placeholder for future persistence
}
const handleLogout = async () => {
await logout()
await navigateTo('/profiles')
}
onMounted(async () => {
await ensureSession()
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

381
frontend/app/assets/app.css Normal file
View File

@@ -0,0 +1,381 @@
/* ─── Fonts ─── */
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap');
@import "tailwindcss";
@plugin "daisyui";
/* ─── Theme ─── */
@plugin "daisyui/theme" {
name: "mytheme";
default: true;
prefersdark: false;
color-scheme: light;
/* Surfaces — warm gray with a hint of blue */
--color-base-100: oklch(98.5% 0.004 260);
--color-base-200: oklch(95% 0.008 260);
--color-base-300: oklch(91% 0.015 260);
--color-base-content: oklch(22% 0.025 260);
/* Primary — Malio blue, slightly richer */
--color-primary: oklch(40% 0.16 262);
--color-primary-content: oklch(98% 0.005 262);
/* Secondary — refined lavender */
--color-secondary: oklch(72% 0.06 275);
--color-secondary-content: oklch(22% 0.03 275);
/* Accent — warm amber-orange */
--color-accent: oklch(72% 0.17 55);
--color-accent-content: oklch(20% 0.04 55);
/* Neutral — deep slate */
--color-neutral: oklch(28% 0.04 260);
--color-neutral-content: oklch(95% 0.005 260);
/* Semantic */
--color-info: oklch(58% 0.14 255);
--color-info-content: oklch(98% 0.005 255);
--color-success: oklch(62% 0.19 150);
--color-success-content: oklch(98% 0.005 150);
--color-warning: oklch(78% 0.15 70);
--color-warning-content: oklch(22% 0.05 70);
--color-error: oklch(58% 0.22 25);
--color-error-content: oklch(98% 0.005 25);
/* Geometry */
--radius-selector: 0.75rem;
--radius-field: 0.375rem;
--radius-box: 0.625rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
@plugin "daisyui/theme" {
name: "mytheme-dark";
default: false;
prefersdark: true;
color-scheme: dark;
/* Surfaces — dark blue-gray */
--color-base-100: oklch(22% 0.015 260);
--color-base-200: oklch(18% 0.012 260);
--color-base-300: oklch(28% 0.018 260);
--color-base-content: oklch(92% 0.005 260);
/* Primary — Malio blue, brighter for dark */
--color-primary: oklch(55% 0.18 262);
--color-primary-content: oklch(98% 0.005 262);
/* Secondary — refined lavender */
--color-secondary: oklch(72% 0.06 275);
--color-secondary-content: oklch(22% 0.03 275);
/* Accent — warm amber-orange */
--color-accent: oklch(72% 0.17 55);
--color-accent-content: oklch(20% 0.04 55);
/* Neutral — lighter slate for dark mode */
--color-neutral: oklch(75% 0.02 260);
--color-neutral-content: oklch(18% 0.01 260);
/* Semantic */
--color-info: oklch(62% 0.14 255);
--color-info-content: oklch(98% 0.005 255);
--color-success: oklch(65% 0.19 150);
--color-success-content: oklch(98% 0.005 150);
--color-warning: oklch(78% 0.15 70);
--color-warning-content: oklch(22% 0.05 70);
--color-error: oklch(62% 0.22 25);
--color-error-content: oklch(98% 0.005 25);
/* Geometry — same as light */
--radius-selector: 0.75rem;
--radius-field: 0.375rem;
--radius-box: 0.625rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
/* ─── Typography ─── */
:root {
--font-heading: 'Outfit', system-ui, sans-serif;
--font-body: 'DM Sans', system-ui, sans-serif;
}
body {
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.01em;
}
h1, h2, h3, h4, h5, h6,
.card-title,
.stat-value,
.text-2xl,
.text-3xl,
.text-4xl {
font-family: var(--font-heading);
letter-spacing: -0.025em;
}
/* ─── Density variables ─── */
:root {
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
}
.density-compact {
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 0.75rem;
--spacing-lg: 1rem;
--spacing-xl: 1.25rem;
}
.density-comfortable {
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
}
.density-spacious {
--spacing-xs: 0.75rem;
--spacing-sm: 1rem;
--spacing-md: 1.5rem;
--spacing-lg: 2rem;
--spacing-xl: 3rem;
}
/* ─── High contrast mode ─── */
.contrast-high .btn { @apply border-2; }
.contrast-high .input { @apply border-2; }
.contrast-high .select { @apply border-2; }
.contrast-high .textarea { @apply border-2; }
.contrast-high .modal-box { @apply border-2 border-base-content; }
/* ─── Accessibility ─── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
*:focus-visible {
outline: 2px solid oklch(40% 0.16 262);
outline-offset: 2px;
}
/* ─── Cards ─── */
.card {
border: 1px solid oklch(91% 0.015 260 / 0.6);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.site-card {
background-color: oklch(100% 0 0);
}
[data-theme="mytheme-dark"] .site-card {
background-color: oklch(24% 0.015 260);
}
[data-theme="mytheme-dark"] .card {
border-color: oklch(30% 0.02 260 / 0.6);
}
.card:hover {
box-shadow:
0 4px 6px -1px oklch(22% 0.025 260 / 0.06),
0 2px 4px -2px oklch(22% 0.025 260 / 0.04);
}
/* ─── Navbar glass effect ─── */
.navbar-glass {
background: oklch(98.5% 0.004 260 / 0.82);
backdrop-filter: blur(12px) saturate(1.5);
-webkit-backdrop-filter: blur(12px) saturate(1.5);
border-bottom: 1px solid oklch(91% 0.015 260 / 0.5);
}
[data-theme="mytheme-dark"] .navbar-glass {
background: oklch(22% 0.015 260 / 0.85);
border-bottom-color: oklch(30% 0.02 260 / 0.5);
}
/* ─── Buttons ─── */
.btn {
font-family: var(--font-heading);
font-weight: 500;
letter-spacing: -0.01em;
transition: all 0.15s ease;
}
.btn-circle {
transition: all 0.2s ease-in-out;
}
.btn-circle:hover {
transform: scale(1.05);
}
.btn-circle:active {
transform: scale(0.95);
}
/* ─── Inputs ─── */
.input, .select, .textarea {
font-family: var(--font-body);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.input:focus, .select:focus, .textarea:focus {
box-shadow: 0 0 0 3px oklch(40% 0.16 262 / 0.1);
}
/* ─── Tables ─── */
.table thead th {
font-family: var(--font-heading);
font-weight: 600;
letter-spacing: 0.01em;
text-transform: uppercase;
font-size: 0.7rem;
color: oklch(45% 0.03 260);
}
.table tbody tr {
transition: background-color 0.1s ease;
}
/* ─── Badges ─── */
.badge {
font-family: var(--font-heading);
font-weight: 500;
letter-spacing: 0;
}
/* ─── Stats ─── */
.stat-title {
font-family: var(--font-body);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.7rem;
font-weight: 500;
opacity: 0.6;
}
.stat-value {
font-weight: 700;
}
/* ─── Modals ─── */
.modal {
transition: opacity 0.25s ease;
}
.modal-box {
font-family: var(--font-body);
border-radius: 0.75rem;
border: 1px solid oklch(91% 0.015 260 / 0.5);
}
@keyframes modalSlideUp {
from { opacity: 0; transform: translateY(0.5rem); }
to { opacity: 1; transform: translateY(0); }
}
.modal.modal-open .modal-box {
animation: modalSlideUp 0.25s ease-out;
}
/* ─── Page transitions ─── */
.page-enter-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-leave-active {
transition: opacity 0.15s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(4px);
}
.page-leave-to {
opacity: 0;
}
/* ─── Scrollbar styling ─── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(75% 0.02 260);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(60% 0.03 260);
}
/* ─── Readability ─── */
.text-sm { line-height: 1.5; }
.text-xs { line-height: 1.4; }
/* ─── Adaptive spacing ─── */
.p-1 { padding: var(--spacing-xs); }
.p-2 { padding: var(--spacing-sm); }
.p-3 { padding: var(--spacing-md); }
.p-4 { padding: var(--spacing-lg); }
.p-5 { padding: var(--spacing-xl); }
.m-1 { margin: var(--spacing-xs); }
.m-2 { margin: var(--spacing-sm); }
.m-3 { margin: var(--spacing-md); }
.m-4 { margin: var(--spacing-lg); }
.m-5 { margin: var(--spacing-xl); }
.gap-1 { gap: var(--spacing-xs); }
.gap-2 { gap: var(--spacing-sm); }
.gap-3 { gap: var(--spacing-md); }
.gap-4 { gap: var(--spacing-lg); }
.gap-5 { gap: var(--spacing-xl); }
@layer components {
.form-control .label {
@apply mb-2;
padding-bottom: 0;
margin-right: 15px;
}
.form-control .label + * {
margin-top: var(--spacing-xs);
}
}
@layer base {
label + input,
label + select,
label + textarea,
label + .input,
label + .select,
label + .textarea {
margin-top: var(--spacing-xs);
}
}

View File

@@ -0,0 +1,55 @@
<template>
<div v-if="documents.length" class="space-y-1 mt-2">
<div
v-for="doc in documents"
:key="doc.id"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-2 py-1.5 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<component
:is="documentIcon(doc).component"
class="w-4 h-4 flex-shrink-0"
:class="documentIcon(doc).colorClass"
/>
<span class="truncate">{{ doc.name || doc.filename }}</span>
<span class="text-base-content/40 flex-shrink-0">{{ formatSize(doc.size) }}</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(doc)"
:title="canPreviewDocument(doc) ? 'Consulter' : 'Aperçu non disponible'"
@click="openPreview(doc)"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(doc)"
>
Télécharger
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { CommentDocument } from '~/composables/useComments'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatSize, documentIcon, downloadDocument } from '~/shared/utils/documentDisplayUtils'
defineProps<{
documents: CommentDocument[]
}>()
const openPreview = (doc: CommentDocument) => {
if (!canPreviewDocument(doc)) return
// Open file URL in new tab for preview
if (doc.fileUrl) {
window.open(doc.fileUrl, '_blank')
}
}
</script>

View File

@@ -0,0 +1,279 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold flex items-center gap-2">
<IconLucideMessageSquare class="w-5 h-5" />
Commentaires
<span v-if="openComments.length" class="badge badge-warning badge-sm">
{{ openComments.length }}
</span>
</h3>
<button
v-if="showResolved && resolvedComments.length"
type="button"
class="btn btn-ghost btn-xs"
@click="showResolvedList = !showResolvedList"
>
{{ showResolvedList ? 'Masquer résolus' : `Voir résolus (${resolvedComments.length})` }}
</button>
</div>
<!-- Formulaire d'ajout -->
<div class="space-y-2">
<div class="flex gap-2">
<textarea
v-model="newContent"
class="textarea textarea-bordered flex-1 text-sm"
rows="2"
placeholder="Ajouter un commentaire..."
:disabled="submitting"
@keydown.ctrl.enter="handleSubmit"
/>
<div class="flex flex-col gap-1 self-end">
<label
class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
data-tip="Joindre des fichiers"
>
<IconLucidePaperclip class="w-4 h-4" />
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFilesSelected"
/>
</label>
<button
type="button"
class="btn btn-primary btn-sm btn-square"
:disabled="!newContent.trim() || submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
<IconLucideSend v-else class="w-4 h-4" />
</button>
</div>
</div>
<!-- Selected files preview -->
<div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
<span
v-for="(file, i) in selectedFiles"
:key="i"
class="badge badge-sm badge-outline gap-1"
>
<IconLucideFile class="w-3 h-3" />
{{ file.name }}
<button type="button" class="ml-1" @click="removeFile(i)">
<IconLucideX class="w-3 h-3" />
</button>
</span>
</div>
</div>
<!-- Liste des commentaires ouverts -->
<div v-if="loadingComments" class="flex justify-center py-4">
<span class="loading loading-spinner loading-sm" />
</div>
<div v-else-if="openComments.length === 0" class="text-sm text-base-content/50 py-2">
Aucun commentaire ouvert.
</div>
<div v-else class="space-y-3">
<div
v-for="comment in openComments"
:key="comment.id"
class="bg-base-200 rounded-lg p-3 space-y-2"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
<!-- Documents attachés -->
<CommentDocumentList :documents="getDocuments(comment)" />
</div>
</div>
<div class="flex items-center justify-between text-xs text-base-content/60">
<span>
{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}
</span>
<div v-if="canEdit" class="flex gap-1">
<button
type="button"
class="btn btn-success btn-xs gap-1"
:disabled="loading"
@click="handleResolve(comment.id)"
>
<IconLucideCheck class="w-3 h-3" />
Résoudre
</button>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loading"
@click="handleDelete(comment.id)"
>
<IconLucideTrash2 class="w-3 h-3" />
</button>
</div>
</div>
</div>
</div>
<!-- Commentaires résolus -->
<div v-if="showResolvedList && resolvedComments.length" class="space-y-2">
<div class="divider text-xs text-base-content/40">
Résolus
</div>
<div
v-for="comment in resolvedComments"
:key="comment.id"
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
>
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
<!-- Documents attachés (résolus) -->
<CommentDocumentList :documents="getDocuments(comment)" />
<div class="flex items-center justify-between text-xs text-base-content/50">
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
<span v-if="comment.resolvedByName">
Résolu par {{ comment.resolvedByName }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
import { usePermissions } from '~/composables/usePermissions'
import CommentDocumentList from '~/components/CommentDocumentList.vue'
import IconLucideMessageSquare from '~icons/lucide/message-square'
import IconLucideSend from '~icons/lucide/send'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import IconLucidePaperclip from '~icons/lucide/paperclip'
import IconLucideFile from '~icons/lucide/file'
import IconLucideX from '~icons/lucide/x'
const props = defineProps<{
entityType: string
entityId: string
entityName?: string
showResolved?: boolean
}>()
const { canEdit } = usePermissions()
const {
loading,
fetchComments,
createComment,
resolveComment,
deleteComment,
} = useComments()
const comments = ref<Comment[]>([])
const newContent = ref('')
const submitting = ref(false)
const loadingComments = ref(false)
const showResolvedList = ref(false)
const selectedFiles = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const getDocuments = (comment: Comment): CommentDocument[] =>
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
const openComments = computed(() =>
comments.value.filter(c => c.status === 'open'),
)
const resolvedComments = computed(() =>
comments.value.filter(c => c.status === 'resolved'),
)
const formatCommentDate = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
const handleFilesSelected = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files) {
selectedFiles.value.push(...Array.from(input.files))
}
input.value = ''
}
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
}
const loadComments = async () => {
loadingComments.value = true
const [openResult, resolvedResult] = await Promise.all([
fetchComments(props.entityType, props.entityId, 'open'),
props.showResolved
? fetchComments(props.entityType, props.entityId, 'resolved')
: Promise.resolve({ success: true, data: [] as Comment[] }),
])
const open = openResult.success ? (openResult.data ?? []) : []
const resolved = resolvedResult.success ? (resolvedResult.data ?? []) : []
comments.value = [...open, ...resolved]
loadingComments.value = false
}
const handleSubmit = async () => {
const content = newContent.value.trim()
if (!content) return
submitting.value = true
const result = await createComment(
props.entityType,
props.entityId,
content,
props.entityName,
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
)
submitting.value = false
if (result.success) {
newContent.value = ''
selectedFiles.value = []
await loadComments()
}
}
const handleResolve = async (commentId: string) => {
const result = await resolveComment(commentId)
if (result.success) {
await loadComments()
}
}
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)
}
}
onMounted(() => {
if (props.entityId) {
loadComments()
}
})
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div class="space-y-4">
<!-- Root Components -->
<div v-for="component in components" :key="component.id" class="border border-gray-200 rounded-lg p-4">
<ComponentItem
:component="component"
:is-edit-mode="isEditMode"
:show-delete="showDelete"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('delete')"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</div>
</div>
</template>
<script setup>
import ComponentItem from './ComponentItem.vue'
defineProps({
components: {
type: Array,
required: true
},
isEditMode: {
type: Boolean,
default: false
},
showDelete: {
type: Boolean,
default: false
},
collapseAll: {
type: Boolean,
default: true
},
toggleToken: {
type: Number,
default: 0
}
})
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
</script>

View File

@@ -0,0 +1,598 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="componentDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<!-- Component Header -->
<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 }"
aria-hidden="true"
/>
<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'">
<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"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
title="Cliquer pour associer un item"
@click.stop="$emit('fill-entity', component.linkId, component.modelTypeId)"
>
À remplir
</button>
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span>
</div>
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
<span
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-xs text-base-content/50"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span>
<span v-if="displayProductName" class="badge badge-info badge-xs">
{{ displayProductName }}
</span>
</div>
</div>
<button
v-if="showDelete"
type="button"
class="btn btn-ghost btn-xs text-error shrink-0"
title="Supprimer ce composant"
@click.stop="$emit('delete')"
>
Supprimer
</button>
</div>
<!-- Expanded content -->
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7">
<!-- Info fields -->
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
<ConstructeurSelect
class="w-full"
:model-value="componentConstructeurIds"
:initial-options="componentConstructeursDisplay"
@update:model-value="handleConstructeurChange"
/>
</div>
</div>
<!-- Read-only info -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
<div>
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
<p class="text-base-content">{{ component.name }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Référence</p>
<p class="text-base-content">{{ component.reference || '—' }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Prix</p>
<p class="text-base-content">{{ component.prix ? `${component.prix}` : '—' }}</p>
</div>
<div>
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p>
<div v-if="componentConstructeursDisplay.length">
<p
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-base-content"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
{{ formatConstructeurContact(constructeur) }}
</span>
</p>
</div>
<p v-else class="text-base-content"></p>
</div>
</div>
<!-- Product -->
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-xs text-base-content/40">Produit catalogue</p>
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/60"
>
{{ info.label }} : {{ info.value }}
</p>
</div>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}`"
class="btn btn-ghost btn-xs shrink-0"
>
Voir le produit
</NuxtLink>
</div>
<!-- Product documents -->
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-4 w-4"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<span class="truncate text-base-content">{{ document.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
title="Champs personnalisés item"
:editable="false"
@field-blur="updateComponentCustomField"
/>
<template v-if="mergedContextFields.length">
<div class="divider my-4 text-xs text-base-content/50">
Champs personnalisés machine
</div>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<!-- Documents -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</div>
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
Chargement...
</p>
<DocumentUpload
v-if="isEditMode"
v-model="selectedFiles"
title="Déposer des fichiers pour ce composant"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded"
/>
<DocumentListInline
:documents="componentDocuments"
:can-delete="isEditMode"
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à ce composant."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<!-- Component Pieces (real MachinePieceLinks) -->
<div v-if="linkedPieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces du composant
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in linkedPieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode"
@update="updatePiece"
@edit="editPiece"
@custom-field-update="updatePieceCustomField"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</div>
</div>
<!-- Structure pieces (read-only, from composant definition) -->
<div v-if="structurePieces.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces incluses par défaut
</p>
<div class="space-y-2">
<PieceItem
v-for="piece in structurePieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="false"
/>
</div>
</div>
<!-- Sub Components -->
<div v-if="childComponents.length > 0" class="space-y-2">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Sous-composants
</p>
<div class="space-y-2 pl-4 border-l-2 border-base-200">
<ComponentItem
v-for="subComponent in childComponents"
:key="subComponent.id"
:component="subComponent"
:is-edit-mode="isEditMode"
:collapse-all="collapseAll"
:toggle-token="toggleToken"
@update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import PieceItem from './PieceItem.vue'
import DocumentUpload from './DocumentUpload.vue'
import ConstructeurSelect from './ConstructeurSelect.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useConstructeurs } from '~/composables/useConstructeurs'
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
import {
shouldInlinePdf,
documentPreviewSrc,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
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 },
isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
// --- Shared composables ---
const {
documents: componentDocuments,
selectedFiles,
uploadingDocuments,
loadingDocuments,
previewDocument,
previewVisible,
openPreview,
closePreview,
ensureDocumentsLoaded,
handleFilesAdded,
removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
const {
displayProduct,
displayProductName,
productInfoRows,
productDocuments,
} = useEntityProductDisplay({ entity: () => props.component })
const {
updateCustomFieldValue: updateCustomFieldValueApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Parent already pre-merges standalone custom fields into props.component.customFields
const displayedCustomFields = computed(() => {
const fields = props.component?.customFields
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
})
const updateComponentCustomField = async (field) => {
if (!field || field.readOnly) return
const e = props.component
const fieldValueId = field.customFieldValueId
if (fieldValueId) {
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
}
return
}
if (!e?.id) {
showError('Impossible de créer la valeur pour ce champ')
return
}
const metadata = field.customFieldId ? undefined : {
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
}
const result = await upsertCustomFieldValue(
field.customFieldId,
'composant',
e.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
}
}
showSuccess(`Champ "${field.name}" créé avec succès`)
} else {
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
}
}
// Context fields are NOT pre-merged — merge locally
const mergedContextFields = computed(() => {
const definitions = props.component?.contextCustomFields ?? []
const values = props.component?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return mergeDefinitionsWithValues(definitions, values)
})
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.component?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId
const customFieldValueId = field.customFieldValueId
if (!customFieldId && !customFieldValueId) return
field.value = value
emit('custom-field-update', {
entityType: 'machineComponentLink',
entityId: linkId,
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || 'Champ contextuel',
})
}
// --- Document edit modal ---
const editingDocument = ref(null)
const editModalVisible = ref(false)
const openEditModal = (doc) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data) => {
if (!editingDocument.value?.id) return
await editDocument(editingDocument.value.id, data)
editModalVisible.value = false
editingDocument.value = null
}
// --- Collapse state ---
const isCollapsed = ref(true)
watch(
() => props.toggleToken,
() => {
isCollapsed.value = props.collapseAll
if (!isCollapsed.value) ensureDocumentsLoaded()
},
{ immediate: true },
)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
if (!isCollapsed.value) ensureDocumentsLoaded()
}
// --- Child components ---
const childComponents = computed(() => {
const list = props.component.subcomponents || props.component.subComponents || []
return Array.isArray(list) ? list : []
})
// --- Pieces split: real links vs structure definitions ---
const allPieces = computed(() => {
const list = props.component.pieces
return Array.isArray(list) ? list : []
})
const linkedPieces = computed(() => allPieces.value.filter((p) => !p._structurePiece))
const structurePieces = computed(() => allPieces.value.filter((p) => p._structurePiece))
// --- Constructeurs ---
const { constructeurs } = useConstructeurs()
const componentConstructeurLinks = computed(() =>
parseConstructeurLinksFromApi(
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
),
)
const supplierReferenceMap = computed(() => {
const map = new Map()
componentConstructeurLinks.value.forEach(l => {
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
})
return map
})
const componentConstructeurIds = computed(() =>
componentConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
)
const componentConstructeursDisplay = computed(() => {
// Extract nested constructeur objects from link entries
const linkConstructeurs = componentConstructeurLinks.value
.filter(l => l.constructeur && l.constructeur.id)
.map(l => l.constructeur)
return resolveConstructeurs(
componentConstructeurIds.value,
linkConstructeurs,
constructeurs.value,
)
})
const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur)
const handleConstructeurChange = async (value) => {
const ids = uniqueConstructeurIds(value)
props.component.constructeurIds = [...ids]
props.component.constructeurId = null
props.component.constructeur = null
props.component.constructeurs = resolveConstructeurs(
ids,
constructeurs.value,
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
)
await updateComponent()
}
// --- Update / Event forwarding ---
const updateComponent = () => {
emit('update', {
...props.component,
constructeurIds: componentConstructeurIds.value,
})
}
const updatePiece = (updatedPiece) => {
emit('edit-piece', updatedPiece)
}
const editPiece = (piece) => {
emit('edit-piece', piece)
}
const updatePieceCustomField = (fieldUpdate) => {
emit('custom-field-update', fieldUpdate)
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
}
</script>

View File

@@ -0,0 +1,269 @@
<template>
<div class="space-y-6">
<StructureNodeEditor
:node="localStructure"
:depth="0"
:component-types="availableComponentTypes"
:piece-types="availablePieceTypes"
:product-types="availableProductTypes"
:lock-type="lockRootType"
:locked-type-label="displayedRootTypeLabel"
:allow-subcomponents="allowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
is-root
/>
</div>
</template>
<script setup lang="ts">
import { reactive, watch, computed, onMounted, ref } from 'vue'
import StructureNodeEditor from '~/components/StructureNodeEditor.vue'
import {
defaultStructure,
hydrateStructureForEditor,
cloneStructure,
} from '~/shared/modelUtils'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
defineOptions({ name: 'ComponentModelStructureEditor' })
const props = defineProps({
modelValue: {
type: Object,
default: () => defaultStructure(),
},
rootTypeId: {
type: String,
default: '',
},
rootTypeLabel: {
type: String,
default: '',
},
lockRootType: {
type: Boolean,
default: false,
},
allowSubcomponents: {
type: Boolean,
default: true,
},
maxSubcomponentDepth: {
type: Number,
default: Infinity,
},
})
const emit = defineEmits(['update:modelValue'])
const localStructure = reactive<ComponentModelStructure>(hydrateStructureForEditor(props.modelValue))
const previousLockedLabel = ref(props.rootTypeLabel || '')
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const availablePieceTypes = computed(() => pieceTypes.value ?? [])
const availableComponentTypes = computed(() => componentTypes.value ?? [])
const availableProductTypes = computed(() => productTypes.value ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
)
const fallbackRootTypeLabel = computed(() => {
if (!props.rootTypeId) {
return ''
}
const match = availableComponentTypes.value.find((type) => type?.id === props.rootTypeId)
return match?.name || ''
})
const displayedRootTypeLabel = computed(() => props.rootTypeLabel || fallbackRootTypeLabel.value)
const formatOptionsText = (field: Record<string, any>) => {
if (typeof field?.optionsText === 'string') {
return field.optionsText
}
if (Array.isArray(field?.options)) {
return field.options.join('\n')
}
return ''
}
const normalizeLineEndings = (text: string) =>
text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const parseOptionsFromText = (text: string) => {
return text
.split('\n')
.map((option) => option.trim())
.filter((option) => option.length > 0)
}
const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) => {
if (!node || typeof node !== 'object') {
return
}
if (Array.isArray(node.customFields)) {
node.customFields = node.customFields.map((field: Record<string, any>) => {
if (!field || typeof field !== 'object') {
return field
}
const next = { ...field }
if (next.type === 'select') {
const baseText = normalizeLineEndings(formatOptionsText(next))
if (next.optionsText !== baseText) {
next.optionsText = baseText
}
const parsedOptions = parseOptionsFromText(next.optionsText || '')
if (parsedOptions.length > 0) {
next.options = parsedOptions
} else {
delete next.options
}
} else {
if (next.options !== undefined) {
delete next.options
}
if (next.optionsText !== undefined && next.optionsText !== '') {
next.optionsText = ''
}
}
return next
})
}
if (Array.isArray(node.subcomponents)) {
node.subcomponents = node.subcomponents.map((sub: Record<string, any>) => {
if (!sub || typeof sub !== 'object') {
return sub
}
const copy = { ...sub }
applyCustomFieldOptions(copy)
return copy
})
}
}
const prepareStructureForEmit = (structure: any) => {
const clone = cloneStructure(structure)
applyCustomFieldOptions(clone as Record<string, any>)
return clone
}
const syncRootType = () => {
if (!props.lockRootType) {
previousLockedLabel.value = props.rootTypeLabel || ''
return
}
const newTypeId = props.rootTypeId || ''
const newLabel = displayedRootTypeLabel.value
localStructure.typeComposantId = newTypeId
localStructure.typeComposantLabel = newLabel
const match = availableComponentTypes.value.find((type) => type?.id === newTypeId)
if (match?.code) {
localStructure.familyCode = match.code
}
const previousLabel = previousLockedLabel.value
if (!localStructure.alias || localStructure.alias === previousLabel || localStructure.alias === '') {
localStructure.alias = newLabel || localStructure.alias
}
previousLockedLabel.value = newLabel
}
let lastEmitted = JSON.stringify(prepareStructureForEmit(props.modelValue))
const syncFromProps = (value: any) => {
const normalizedIncoming = prepareStructureForEmit(value)
const incomingSerialized = JSON.stringify(normalizedIncoming)
if (incomingSerialized === lastEmitted) {
return
}
const hydrated = hydrateStructureForEditor(value)
localStructure.customFields = hydrated.customFields
localStructure.pieces = hydrated.pieces
localStructure.products = hydrated.products
localStructure.subcomponents = hydrated.subcomponents
localStructure.typeComposantId = hydrated.typeComposantId
localStructure.typeComposantLabel = hydrated.typeComposantLabel
localStructure.modelId = hydrated.modelId
localStructure.familyCode = hydrated.familyCode
localStructure.alias = hydrated.alias
lastEmitted = incomingSerialized
syncRootType()
}
watch(
() => props.modelValue,
(value) => {
syncFromProps(value)
},
{ deep: true }
)
watch(
() => [props.rootTypeId, props.rootTypeLabel, props.lockRootType],
() => {
syncRootType()
},
{ immediate: true }
)
watch(
availableComponentTypes,
() => {
syncRootType()
}
)
watch(
localStructure,
(value) => {
const payload = prepareStructureForEmit(value)
const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) {
lastEmitted = serialized
emit('update:modelValue', payload)
}
},
{ deep: true }
)
onMounted(async () => {
const loaders: Promise<unknown>[] = []
if (!availablePieceTypes.value.length) {
loaders.push(loadPieceTypes())
}
if (!availableComponentTypes.value.length) {
loaders.push(loadComponentTypes())
}
if (!availableProductTypes.value.length) {
loaders.push(loadProductTypes())
}
if (loaders.length) {
await Promise.allSettled(loaders)
}
syncRootType()
})
watch(
allowSubcomponents,
(allowed) => {
if (!allowed && Array.isArray(localStructure.subcomponents) && localStructure.subcomponents.length) {
localStructure.subcomponents = []
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,262 @@
<template>
<div :class="wrapperClass">
<section v-if="!isRoot" class="rounded-lg border border-base-200 bg-base-100 p-4 space-y-3">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ requirementLabel }}
</h4>
<p class="text-xs text-base-content/70">
{{ requirementDescription }}
</p>
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Sélectionner un composant</span>
</label>
<SearchSelect
:model-value="assignment.selectedComponentId || ''"
:options="componentOptions"
:loading="componentsLoading || componentLoadingByPath[assignment.path]"
size="sm"
placeholder="Rechercher un composant..."
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
:option-label="componentOptionLabel"
:option-description="componentOptionDescription"
server-search
@search="fetchComponentOptions"
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
/>
</div>
</section>
<section v-if="assignment.pieces.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
<header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ isRoot ? 'Pièces requises par le squelette' : 'Pièces associées à ce sous-composant' }}
</h4>
<p class="text-xs text-base-content/70">
Sélectionnez les pièces concrètes à associer pour chaque emplacement.
</p>
</header>
<div
v-for="pieceAssignment in assignment.pieces"
:key="pieceAssignment.path"
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
>
<div class="space-y-1">
<p class="text-xs font-medium text-base-content">
{{ describePieceRequirement(pieceAssignment) }}
</p>
<p v-if="!getPieceOptions(pieceAssignment).length" class="text-[11px] text-error">
Aucune pièce disponible pour cette famille.
</p>
</div>
<SearchSelect
:model-value="pieceAssignment.selectedPieceId || ''"
:options="getPieceOptions(pieceAssignment)"
:loading="piecesLoading || pieceLoadingByPath[pieceAssignment.path]"
size="xs"
placeholder="Rechercher une pièce..."
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
:option-label="pieceOptionLabel"
:option-description="pieceOptionDescription"
server-search
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
/>
</div>
</section>
<section v-if="assignment.products.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
<header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ isRoot ? 'Produits requis par le squelette' : 'Produits associés à ce sous-composant' }}
</h4>
<p class="text-xs text-base-content/70">
Sélectionnez les produits catalogue à lier sur chaque position définie.
</p>
</header>
<div
v-for="productAssignment in assignment.products"
:key="productAssignment.path"
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
>
<div class="space-y-1">
<p class="text-xs font-medium text-base-content">
{{ describeProductRequirement(productAssignment) }}
</p>
<p v-if="!getProductOptions(productAssignment).length" class="text-[11px] text-error">
Aucun produit disponible pour cette catégorie.
</p>
</div>
<SearchSelect
:model-value="productAssignment.selectedProductId || ''"
:options="getProductOptions(productAssignment)"
:loading="productsLoading || productLoadingByPath[productAssignment.path]"
size="xs"
placeholder="Rechercher un produit..."
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
:option-label="productOptionLabel"
:option-description="productOptionDescription"
server-search
@search="(term) => fetchProductOptions(productAssignment, term)"
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
/>
</div>
</section>
<section v-if="assignment.subcomponents.length" class="space-y-4">
<header class="space-y-1">
<h4 class="text-sm font-semibold text-base-content">
{{ isRoot ? 'Sous-composants définis par le squelette' : 'Sous-composants imbriqués' }}
</h4>
<p class="text-xs text-base-content/70">
Choisissez un composant existant pour chaque sous-niveau requis.
</p>
</header>
<ComponentStructureAssignmentNode
v-for="subAssignment in assignment.subcomponents"
:key="subAssignment.path"
:assignment="subAssignment"
:pieces="pieces"
:products="products"
:components="components"
:components-loading="componentsLoading"
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:depth="depth + 1"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue';
import { useStructureAssignmentFetch } from '~/composables/useStructureAssignmentFetch';
import type {
ComponentOption,
PieceOption,
ProductOption,
} from '~/composables/useStructureAssignmentFetch';
export type {
StructureAssignmentNode,
StructurePieceAssignment,
StructureProductAssignment,
} from '~/composables/useStructureAssignmentFetch';
const props = withDefaults(
defineProps<{
assignment: import('~/composables/useStructureAssignmentFetch').StructureAssignmentNode;
pieces: PieceOption[] | null;
products: ProductOption[] | null;
components: ComponentOption[] | null;
depth?: number;
componentsLoading?: boolean;
piecesLoading?: boolean;
productsLoading?: boolean;
pieceTypeLabelMap?: Record<string, string>;
productTypeLabelMap?: Record<string, string>;
componentTypeLabelMap?: Record<string, string>;
}>(),
{
depth: 0,
pieces: () => [],
products: () => [],
components: () => [],
componentsLoading: false,
piecesLoading: false,
productsLoading: false,
pieceTypeLabelMap: () => ({}),
productTypeLabelMap: () => ({}),
componentTypeLabelMap: () => ({}),
},
);
const depth = computed(() => props.depth ?? 0);
const isRoot = computed(() => depth.value === 0);
const wrapperClass = computed(() =>
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
);
const {
pieceLoadingByPath,
productLoadingByPath,
componentLoadingByPath,
componentOptions,
componentOptionLabel,
componentOptionDescription,
fetchComponentOptions,
getPieceOptions,
pieceOptionLabel,
pieceOptionDescription,
fetchPieceOptions,
describePieceRequirement,
getProductOptions,
productOptionLabel,
productOptionDescription,
fetchProductOptions,
describeProductRequirement,
} = useStructureAssignmentFetch({
assignment: props.assignment,
pieces: props.pieces,
products: props.products,
components: props.components,
isRoot: () => isRoot.value,
pieceTypeLabelMap: props.pieceTypeLabelMap ?? {},
productTypeLabelMap: props.productTypeLabelMap ?? {},
componentTypeLabelMap: props.componentTypeLabelMap ?? {},
});
const normalizeSelectionValue = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return String(value);
}
return '';
};
const requirementLabel = computed(() => {
const definition = props.assignment.definition || {};
const alias = definition.alias || definition.typeComposantLabel;
if (alias) {
return alias;
}
if (definition.typeComposantId && props.componentTypeLabelMap[definition.typeComposantId]) {
return props.componentTypeLabelMap[definition.typeComposantId];
}
if (definition.typeComposant?.name) {
return definition.typeComposant.name;
}
if (definition.familyCode) {
return `Famille ${definition.familyCode}`;
}
return 'Sous-composant';
});
const requirementDescription = computed(() => {
const definition = props.assignment.definition || {};
const family =
definition.typeComposantLabel
|| (definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null)
|| definition.typeComposant?.name
|| definition.familyCode;
if (family) {
return `Doit appartenir à la famille "${family}".`;
}
return 'Sélectionnez un composant enfant conforme à cette position.';
});
</script>

View File

@@ -0,0 +1,130 @@
<template>
<div class="space-y-1">
<SearchSelect
:model-value="modelValue ?? undefined"
:options="composantOptions"
:loading="loading"
:placeholder="placeholder"
:empty-text="emptyText"
size="sm"
option-value="id"
:option-label="formatLabel"
:disabled="disabled"
server-search
@update:modelValue="updateValue"
@search="handleSearch"
>
<template #option-description="{ option }">
<span class="text-xs text-base-content/60">
{{ formatDescription(option) }}
</span>
</template>
</SearchSelect>
<p v-if="helperText" class="text-xs text-base-content/60">
{{ helperText }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComposants } from '~/composables/useComposants'
const props = withDefaults(
defineProps<{
modelValue?: string | null
placeholder?: string
emptyText?: string
helperText?: string
disabled?: boolean
typeComposantId?: string | null
}>(),
{
modelValue: '',
placeholder: 'Sélectionner un composant…',
emptyText: 'Aucun composant disponible',
helperText: '',
disabled: false,
typeComposantId: null,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const { loading: globalLoading, loadComposants } = useComposants()
const localComposants = ref<any[]>([])
const localLoading = ref(false)
const loading = computed(() => localLoading.value || globalLoading.value)
const composantOptions = computed(() => localComposants.value)
const loadFilteredComposants = async (search = '') => {
if (!props.typeComposantId) return
localLoading.value = true
try {
const result = await loadComposants({ typeComposantId: props.typeComposantId, search, itemsPerPage: 200, force: true })
if (result.success && result.data?.items) {
localComposants.value = result.data.items
}
}
catch (error: unknown) {
console.error('Erreur lors du chargement des composants:', error)
}
finally {
localLoading.value = false
}
}
let searchDebounce: ReturnType<typeof setTimeout> | null = null
const handleSearch = (term: string) => {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => loadFilteredComposants(term.trim()), 300)
}
onMounted(() => {
loadFilteredComposants()
})
watch(
() => props.typeComposantId,
() => {
loadFilteredComposants()
},
)
const updateValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === '') {
emit('update:modelValue', null)
return
}
emit('update:modelValue', String(value))
}
const formatLabel = (option: any) => {
if (!option) return ''
const name = option.name || 'Composant'
return option.reference ? `${name}${option.reference}` : name
}
const formatDescription = (option: any) => {
const parts: string[] = []
const typeName = option?.typeComposant?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) {
parts.push(`Ref. ${option.reference}`)
}
if (option?.prix !== undefined && option.prix !== null) {
const price = Number(option.prix)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.length ? parts.join(' • ') : 'Sans référence'
}
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div v-if="modelValue.length" class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Fournisseur</th>
<th>Réf. fournisseur</th>
<th v-if="!readonly" class="w-10" />
</tr>
</thead>
<tbody>
<tr v-for="(link, index) in modelValue" :key="link.constructeurId">
<td class="font-medium">
{{ getConstructeurName(link) }}
<div v-if="getConstructeurContact(link)" class="text-xs text-gray-500">
{{ getConstructeurContact(link) }}
</div>
</td>
<td>
<input
v-if="!readonly"
:value="link.supplierReference || ''"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Réf. fournisseur"
@input="updateReference(index, ($event.target as HTMLInputElement).value)"
>
<span v-else>{{ link.supplierReference || '' }}</span>
</td>
<td v-if="!readonly">
<button
type="button"
class="btn btn-ghost btn-xs text-error"
aria-label="Retirer"
@click="removeLink(index)"
>
<IconLucideX class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { formatConstructeurContact } from '~/shared/constructeurUtils'
import { useConstructeurs } from '~/composables/useConstructeurs'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
type: Array as PropType<ConstructeurLinkEntry[]>,
default: () => [],
},
readonly: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: ConstructeurLinkEntry[]): void
(e: 'remove', constructeurId: string): void
}>()
const { getConstructeurById } = useConstructeurs()
const getConstructeurName = (link: ConstructeurLinkEntry): string =>
link.constructeur?.name || getConstructeurById(link.constructeurId)?.name || link.constructeurId
const getConstructeurContact = (link: ConstructeurLinkEntry): string => {
const c = link.constructeur || getConstructeurById(link.constructeurId)
return formatConstructeurContact(c as any)
}
const updateReference = (index: number, value: string) => {
const updated = [...props.modelValue]
const entry = updated[index]
if (!entry) return
updated[index] = { ...entry, supplierReference: value || null }
emit('update:modelValue', updated)
}
const removeLink = (index: number) => {
const removed = props.modelValue[index]
const updated = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', updated)
if (removed) emit('remove', removed.constructeurId)
}
</script>

View File

@@ -0,0 +1,395 @@
<template>
<div class="space-y-2 constructeur-select">
<label v-if="label" class="label"><span class="label-text">{{ label }}</span></label>
<div class="flex items-start gap-2">
<div class="relative flex-1">
<input
v-model="searchTerm"
type="text"
class="input input-bordered w-full pr-10"
:placeholder="placeholder"
@focus="openDropdown = true; ensureOptionsLoaded()"
@input="onSearch"
>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs"
@click="ensureOptionsLoaded(true)"
>
<IconLucideChevronsUpDown class="w-4 h-4" aria-hidden="true" />
</button>
<div
v-if="openDropdown"
class="absolute z-20 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
>
<div
v-if="filteredOptions.length === 0"
class="px-3 py-2 text-xs text-gray-500"
>
Aucun fournisseur trouvé
</div>
<button
v-for="option in filteredOptions"
:key="option.id"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
:class="{ 'bg-base-200': isSelected(option.id) }"
@click="toggleOption(option)"
>
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col">
<span class="font-medium">{{ option.name }}</span>
<span class="text-xs text-gray-500">
{{ formatConstructeurContact(option) || '—' }}
</span>
</div>
<IconLucideCheck v-if="isSelected(option.id)" class="w-4 h-4 text-primary" aria-hidden="true" />
</div>
</button>
</div>
</div>
<button type="button" class="btn btn-outline btn-sm" @click="openCreateModal = true">
Nouveau
</button>
</div>
<div class="flex flex-wrap gap-2 min-h-[1.5rem]">
<span v-if="!selectedConstructeurs.length" class="text-sm text-gray-500">
Aucun fournisseur sélectionné
</span>
<span
v-for="constructeur in selectedConstructeurs"
:key="constructeur.id"
class="badge badge-outline gap-1"
>
<span>{{ constructeur.name }}</span>
<button
type="button"
class="btn btn-ghost btn-xs p-0"
aria-label="Retirer le fournisseur"
@click="removeConstructeur(constructeur.id)"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
</span>
</div>
<dialog class="modal" :class="{ 'modal-open': openCreateModal }">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Nouveau fournisseur
</h3>
<form @submit.prevent="handleCreate">
<div class="form-control mb-3">
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="createForm.name" type="text" class="input input-bordered" required>
</div>
<FieldEmail
v-model="createForm.email"
class="mb-3"
label="Email"
placeholder="ex: contact@fournisseur.com"
autocomplete="email"
/>
<FieldPhone
v-model="createForm.phone"
class="mb-3"
label="Téléphone"
placeholder="ex: 01 23 45 67 89"
/>
<div class="modal-action">
<button type="button" class="btn" @click="closeCreateModal">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="creating">
<span v-if="creating" class="loading loading-spinner loading-xs mr-2" />
Créer
</button>
</div>
</form>
</div>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import type { PropType } from 'vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
import {
type ConstructeurSummary,
formatConstructeurContact,
resolveConstructeurs,
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
const props = defineProps({
modelValue: {
type: Array as PropType<string[]>,
default: () => [],
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Sélectionner ou créer un fournisseur...',
},
initialOptions: {
type: Array as PropType<ConstructeurSummary[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const {
constructeurs,
searchConstructeurs,
createConstructeur,
ensureConstructeurs,
} = useConstructeurs()
const searchTerm = ref('')
const openDropdown = ref(false)
const openCreateModal = ref(false)
const creating = ref(false)
const options = ref<ConstructeurSummary[]>([])
const selectedIds = ref<string[]>([])
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
const seen = new Map<string, ConstructeurSummary>()
items.forEach((item) => {
if (item && typeof item === 'object' && typeof item.id === 'string') {
seen.set(item.id, item)
}
})
return Array.from(seen.values())
}
const normalizedInitialOptions = computed(() =>
uniqueOptions((props.initialOptions as ConstructeurSummary[]) || []),
)
const applyOptions = (items: ConstructeurSummary[] = []) => {
options.value = uniqueOptions([
...normalizedInitialOptions.value,
...items,
])
}
const filteredOptions = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
if (!term) return options.value
return options.value.filter((option) =>
(option.name ?? '').toLowerCase().includes(term)
|| (option.email && option.email.toLowerCase().includes(term))
|| (option.phone && option.phone.toLowerCase().includes(term))
)
})
const createForm = ref({
name: '',
email: '',
phone: '',
})
const optionLookup = computed(() => {
const map = new Map<string, ConstructeurSummary>()
normalizedInitialOptions.value.forEach((item) => {
map.set(item.id, item)
})
constructeurs.value.forEach((item: ConstructeurSummary) => {
map.set(item.id, item)
})
options.value.forEach((item) => {
map.set(item.id, item)
})
return map
})
const selectedConstructeurs = computed<ConstructeurSummary[]>(() => {
if (!selectedIds.value.length) {
return []
}
return selectedIds.value
.map((id) => optionLookup.value.get(id))
.filter((item): item is ConstructeurSummary => Boolean(item))
})
const isSelected = (id: string) => selectedIds.value.includes(id)
const emitSelection = (ids: string[]) => {
const normalized = uniqueConstructeurIds(ids)
selectedIds.value = normalized
emit('update:modelValue', normalized)
}
const extractDataArray = (data: unknown): ConstructeurSummary[] => {
if (Array.isArray(data)) {
return data as ConstructeurSummary[]
}
if (data && typeof data === 'object' && 'id' in data) {
return [data as ConstructeurSummary]
}
return []
}
const ensureOptionsLoaded = async (force = false) => {
if (!force && constructeurs.value.length) {
applyOptions(constructeurs.value as ConstructeurSummary[])
return
}
const result = await searchConstructeurs('')
if (result.success) {
applyOptions(extractDataArray(result.data))
}
}
const onSearch = () => {
openDropdown.value = true
ensureOptionsLoaded()
}
const toggleOption = (option: ConstructeurSummary) => {
const ids = new Set(selectedIds.value)
if (ids.has(option.id)) {
ids.delete(option.id)
} else {
ids.add(option.id)
}
emitSelection(Array.from(ids))
}
const removeConstructeur = (id: string) => {
emitSelection(selectedIds.value.filter((item) => item !== id))
}
const closeCreateModal = () => {
openCreateModal.value = false
createForm.value = { name: '', email: '', phone: '' }
}
const handleCreate = async () => {
const trimmedName = createForm.value.name.trim()
const duplicate = options.value.find(
(o) => (o.name ?? '').toLowerCase() === trimmedName.toLowerCase(),
)
if (duplicate) {
emitSelection([...selectedIds.value, duplicate.id])
closeCreateModal()
return
}
creating.value = true
const payload: { name: string; email?: string; phone?: string } = {
name: trimmedName,
}
if (createForm.value.email) {
payload.email = createForm.value.email
}
if (createForm.value.phone) {
payload.phone = createForm.value.phone
}
const result = await createConstructeur(payload)
creating.value = false
if (result.success && result.data && !Array.isArray(result.data)) {
emitSelection([...selectedIds.value, result.data.id])
searchTerm.value = ''
closeCreateModal()
await ensureOptionsLoaded(true)
}
}
const clickHandler = (event: Event) => {
const element = event.target as HTMLElement | null
if (element && element.closest) {
if (
element.closest('.menu') ||
element.closest('.modal-box') ||
element.closest('.btn') ||
element.closest('.constructeur-select')
) {
return
}
}
openDropdown.value = false
}
watch(
() => props.modelValue,
(newValue) => {
selectedIds.value = uniqueConstructeurIds(newValue)
},
{ immediate: true },
)
watch(
selectedIds,
async (ids) => {
if (!ids.length) {
return
}
const missing = ids.some((id) => !optionLookup.value.get(id))
if (missing) {
const fetched = await ensureConstructeurs(ids)
if (fetched.length) {
applyOptions([...options.value, ...fetched])
}
}
},
{ immediate: true },
)
watch(
constructeurs,
(list) => {
applyOptions((list as ConstructeurSummary[]) || [])
},
{ immediate: true },
)
watch(
normalizedInitialOptions,
() => {
applyOptions(options.value)
},
{ immediate: true },
)
onMounted(() => {
window.addEventListener('click', clickHandler)
ensureOptionsLoaded()
})
onBeforeUnmount(() => {
window.removeEventListener('click', clickHandler)
})
watch(
selectedIds,
(ids) => {
// ensure options contain newly selected ids
const resolved = resolveConstructeurs(
ids,
constructeurs.value as ConstructeurSummary[],
options.value,
)
if (resolved.length) {
applyOptions([...resolved, ...options.value])
}
},
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,147 @@
<template>
<div v-if="customFields && customFields.length > 0" class="space-y-4">
<h4 class="font-semibold text-base-content/80 mb-3">
Champs personnalisés
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="field in sortedCustomFields"
:key="field.id"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<!-- Champ de type TEXT -->
<input
v-if="field.type === 'text'"
v-model="fieldValues[field.id]"
type="text"
class="input input-bordered input-sm"
:required="field.required"
@blur="updateCustomFieldValue(field.id)"
>
<!-- Champ de type NUMBER -->
<input
v-else-if="field.type === 'number'"
v-model="fieldValues[field.id]"
type="number"
class="input input-bordered input-sm"
:required="field.required"
@blur="updateCustomFieldValue(field.id)"
>
<!-- Champ de type SELECT -->
<select
v-else-if="field.type === 'select'"
v-model="fieldValues[field.id]"
class="select select-bordered select-sm"
:required="field.required"
@change="updateCustomFieldValue(field.id)"
>
<option value="">
Sélectionner...
</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<!-- Champ de type BOOLEAN -->
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="fieldValues[field.id]"
type="checkbox"
class="toggle toggle-primary toggle-sm"
:checked="fieldValues[field.id] === 'true'"
@change="updateCustomFieldValue(field.id)"
>
<span class="text-sm" :class="fieldValues[field.id] === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<!-- Champ de type DATE -->
<input
v-else-if="field.type === 'date'"
v-model="fieldValues[field.id]"
type="date"
class="input input-bordered input-sm"
:required="field.required"
@blur="updateCustomFieldValue(field.id)"
>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, onMounted, watch, computed } from 'vue'
const props = defineProps({
customFields: {
type: Array,
default: () => []
},
entityId: {
type: String,
required: true
},
entityType: {
type: String,
required: true, // 'machine', 'composant', 'piece'
validator: value => ['machine', 'composant', 'piece'].includes(value)
}
})
const emit = defineEmits(['update'])
const sortedCustomFields = computed(() => {
if (!Array.isArray(props.customFields)) {
return []
}
return [...props.customFields].sort((a, b) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return left - right
})
})
// Valeurs des champs personnalisés
const fieldValues = reactive({})
// Initialiser les valeurs sans appliquer de valeur par défaut implicite
const initializeFieldValues = () => {
props.customFields.forEach((field) => {
if (!(field.id in fieldValues)) {
fieldValues[field.id] = field.value ?? ''
}
})
}
// Mettre à jour la valeur d'un champ personnalisé
const updateCustomFieldValue = (fieldId) => {
const field = props.customFields.find(f => f.id === fieldId)
if (field) {
emit('update', {
fieldId,
value: fieldValues[fieldId],
field
})
}
}
// Surveiller les changements dans les champs personnalisés
watch(() => props.customFields, () => {
initializeFieldValues()
}, { deep: true })
onMounted(() => {
initializeFieldValues()
})
</script>

View File

@@ -0,0 +1,59 @@
<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>
<p v-if="subtitle" class="text-sm text-base-content/70">{{ subtitle }}</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="canEdit"
class="btn btn-primary"
:class="{ 'btn-outline': isEditMode }"
@click="$emit('toggle-edit')"
>
<IconLucideSquarePen 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>
<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>
<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 route = useRoute()
const props = defineProps<{
title: string
subtitle?: string
isEditMode: boolean
canEdit: boolean
backLink: string
backLinkLabel?: string
}>()
defineEmits<{
'toggle-edit': []
}>()
const backDestination = computed(() => {
if (route.query.from === 'machine' && route.query.machineId) {
return `/machine/${route.query.machineId}`
}
return props.backLink
})
const backLabel = computed(() => {
if (route.query.from === 'machine') {
return 'Retour à la machine'
}
return props.backLinkLabel ?? 'Retour au catalogue'
})
</script>

View File

@@ -0,0 +1,207 @@
<template>
<div class="modal" :class="{ 'modal-open': isOpen }">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Paramètres d'affichage
</h3>
<!-- Contrôle du zoom -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Taille du texte</span>
<span class="label-text-alt">{{ zoomLevel }}%</span>
</label>
<input
type="range"
min="80"
max="150"
step="10"
:value="zoomLevel"
class="range range-primary"
@input="updateZoom"
>
<div class="w-full flex justify-between text-xs px-2 mt-1">
<span>80%</span>
<span>100%</span>
<span>150%</span>
</div>
</div>
<!-- Contrôle de la densité -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Densité de l'interface</span>
</label>
<div class="flex gap-2">
<button
class="btn btn-sm"
:class="density === 'compact' ? 'btn-primary' : 'btn-outline'"
@click="setDensity('compact')"
>
Compacte
</button>
<button
class="btn btn-sm"
:class="density === 'comfortable' ? 'btn-primary' : 'btn-outline'"
@click="setDensity('comfortable')"
>
Confortable
</button>
<button
class="btn btn-sm"
:class="density === 'spacious' ? 'btn-primary' : 'btn-outline'"
@click="setDensity('spacious')"
>
Espacée
</button>
</div>
</div>
<!-- Contrôle du contraste -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Contraste</span>
</label>
<div class="flex gap-2">
<button
class="btn btn-sm"
:class="contrast === 'normal' ? 'btn-primary' : 'btn-outline'"
@click="setContrast('normal')"
>
Normal
</button>
<button
class="btn btn-sm"
:class="contrast === 'high' ? 'btn-primary' : 'btn-outline'"
@click="setContrast('high')"
>
Élevé
</button>
</div>
</div>
<!-- Réinitialiser -->
<div class="form-control">
<button
class="btn btn-outline btn-sm"
@click="resetSettings"
>
Réinitialiser les paramètres
</button>
</div>
<!-- Actions -->
<div class="modal-action">
<button class="btn btn-primary" @click="closeModal">
Fermer
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
defineProps({
isOpen: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'update-settings'])
// Paramètres d'affichage
const zoomLevel = ref(100)
const density = ref('comfortable')
const contrast = ref('normal')
// Charger les paramètres depuis le localStorage
onMounted(() => {
const savedZoom = localStorage.getItem('display-zoom')
const savedDensity = localStorage.getItem('display-density')
const savedContrast = localStorage.getItem('display-contrast')
if (savedZoom) { zoomLevel.value = parseInt(savedZoom) }
if (savedDensity) { density.value = savedDensity }
if (savedContrast) { contrast.value = savedContrast }
applySettings()
})
const updateZoom = (event) => {
zoomLevel.value = parseInt(event.target.value)
applySettings()
}
const setDensity = (newDensity) => {
density.value = newDensity
applySettings()
}
const setContrast = (newContrast) => {
contrast.value = newContrast
applySettings()
}
const applySettings = () => {
// Sauvegarder dans localStorage
localStorage.setItem('display-zoom', zoomLevel.value.toString())
localStorage.setItem('display-density', density.value)
localStorage.setItem('display-contrast', contrast.value)
// Appliquer les styles
const root = document.documentElement
// Zoom - exclure complètement le modal des paramètres
const modal = document.querySelector('.modal')
if (modal) {
// Forcer la taille normale pour le modal et tous ses enfants
modal.style.fontSize = '100%'
modal.style.transform = 'none'
modal.style.scale = '1'
// Appliquer aux enfants du modal
const modalElements = modal.querySelectorAll('*')
modalElements.forEach((element) => {
element.style.fontSize = 'inherit'
element.style.transform = 'none'
element.style.scale = '1'
})
}
// Appliquer le zoom au reste de la page (sauf le modal)
root.style.fontSize = `${zoomLevel.value}%`
// Densité - utiliser les classes DaisyUI
root.classList.remove('density-compact', 'density-comfortable', 'density-spacious')
root.classList.add(`density-${density.value}`)
// Contraste - utiliser les classes DaisyUI
root.classList.remove('contrast-normal', 'contrast-high')
root.classList.add(`contrast-${contrast.value}`)
// Émettre les changements
emit('update-settings', {
zoom: zoomLevel.value,
density: density.value,
contrast: contrast.value
})
}
const resetSettings = () => {
zoomLevel.value = 100
density.value = 'comfortable'
contrast.value = 'normal'
applySettings()
}
const closeModal = () => {
emit('close')
}
</script>
<style scoped>
/* Les styles sont maintenant gérés par DaisyUI et le CSS global */
</style>

View File

@@ -0,0 +1,90 @@
<template>
<Teleport to="body">
<div v-if="visible" class="modal modal-open" @click.self="$emit('close')">
<div class="modal-box max-w-sm">
<h3 class="font-bold text-lg mb-4">
Modifier le document
</h3>
<div class="space-y-4">
<label class="form-control w-full">
<div class="label">
<span class="label-text">Nom</span>
</div>
<input
v-model="form.name"
type="text"
class="input input-bordered input-sm md:input-md w-full"
>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Type</span>
</div>
<select
v-model="form.type"
class="select select-bordered select-sm md:select-md w-full"
>
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</label>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="$emit('close')">
Annuler
</button>
<button
type="button"
class="btn btn-primary btn-sm md:btn-md"
:disabled="saving"
@click="save"
>
<span v-if="saving" class="loading loading-spinner loading-xs" />
Sauvegarder
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { reactive, watch, ref } from 'vue'
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
import type { Document } from '~/composables/useDocuments'
const props = defineProps<{
visible: boolean
document: Document | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'updated', data: { name: string; type: string }): void
}>()
const form = reactive({ name: '', type: 'documentation' })
const saving = ref(false)
watch(
() => props.document,
(doc) => {
if (doc) {
form.name = doc.name || ''
form.type = doc.type || 'documentation'
}
},
{ immediate: true },
)
const save = () => {
if (!form.name.trim()) return
saving.value = true
emit('updated', { name: form.name.trim(), type: form.type })
saving.value = false
}
</script>

View File

@@ -0,0 +1,250 @@
<template>
<teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-[1200] flex items-center justify-center bg-black/60 backdrop-blur-sm px-4 py-6"
@click.self="close"
>
<div class="w-full max-w-[1600px] h-full max-h-[94vh] bg-base-100 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
<header class="flex items-start justify-between gap-4 p-6 border-b border-base-200">
<div class="min-w-0">
<h3 class="font-bold text-xl truncate">
Prévisualisation
<span v-if="navTotal > 1" class="text-base font-normal text-base-content/50">
{{ activeIndex + 1 }} / {{ navTotal }}
</span>
</h3>
<p class="text-sm text-base-content/50 truncate">
{{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> &bull; {{ documentDescription }}</span>
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
</button>
</header>
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden relative">
<button
v-if="hasPrev"
type="button"
class="absolute left-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
title="Document précédent (←)"
@click="goToPrev"
>
</button>
<button
v-if="hasNext"
type="button"
class="absolute right-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
title="Document suivant (→)"
@click="goToNext"
>
</button>
<div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden">
<template v-if="previewType === 'image'">
<img :src="documentSrc" alt="preview" class="max-h-full max-w-full object-contain">
</template>
<template v-else-if="previewType === 'pdf'">
<iframe
:src="documentSrc"
class="w-full h-full bg-white"
frameborder="0"
title="Aperçu PDF"
/>
</template>
<template v-else-if="previewType === 'audio'">
<audio :src="documentSrc" controls class="w-full" />
</template>
<template v-else-if="previewType === 'video'">
<video :src="documentSrc" controls class="w-full h-full bg-black" />
</template>
<template v-else-if="previewType === 'text'">
<div class="w-full h-full overflow-auto">
<div v-if="textLoading" class="flex items-center justify-center py-10 text-sm text-base-content/50">
<span class="loading loading-spinner loading-md mr-2" />
Chargement du document...
</div>
<div v-else-if="textError" class="alert alert-error text-sm">
{{ textError }}
</div>
<pre v-else class="bg-base-100 border border-base-300 rounded-lg p-4 whitespace-pre-wrap">
{{ textContent }}
</pre>
</div>
</template>
<template v-else>
<div class="text-sm text-base-content/50 text-center px-6">
Prévisualisation non disponible pour ce type de document.
</div>
</template>
</div>
</section>
<footer class="border-t border-base-200 px-6 py-4 flex flex-wrap gap-2 justify-end bg-base-100">
<button type="button" class="btn" @click="close">
Fermer
</button>
<button type="button" class="btn btn-primary" @click="download">
Télécharger
</button>
</footer>
</div>
</div>
</teleport>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { getPreviewType, describeDocument, canPreviewDocument } from '~/utils/documentPreview'
const props = defineProps({
document: {
type: Object,
default: null,
},
visible: {
type: Boolean,
default: false,
},
documents: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['close'])
// --- Carousel navigation ---
const previewableDocuments = computed(() => {
if (!props.documents?.length) return []
return props.documents.filter((doc) => canPreviewDocument(doc))
})
const navTotal = computed(() => previewableDocuments.value.length)
const activeIndex = ref(0)
// Sync index when the parent changes the document prop (e.g. user clicks a different "Consulter")
watch(
() => props.document,
(doc) => {
if (!doc || !previewableDocuments.value.length) {
activeIndex.value = 0
return
}
const idx = previewableDocuments.value.findIndex((d) => d.id === doc.id)
activeIndex.value = idx >= 0 ? idx : 0
},
{ immediate: true },
)
const activeDoc = computed(() => {
if (previewableDocuments.value.length && activeIndex.value < previewableDocuments.value.length) {
return previewableDocuments.value[activeIndex.value]
}
return props.document
})
const hasPrev = computed(() => navTotal.value > 1 && activeIndex.value > 0)
const hasNext = computed(() => navTotal.value > 1 && activeIndex.value < navTotal.value - 1)
const goToPrev = () => {
if (hasPrev.value) activeIndex.value--
}
const goToNext = () => {
if (hasNext.value) activeIndex.value++
}
// Keyboard navigation
const handleKeydown = (e) => {
if (!props.visible) return
if (e.key === 'ArrowLeft') {
e.preventDefault()
goToPrev()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
goToNext()
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
watch(
() => props.visible,
(val) => {
if (val) {
document.addEventListener('keydown', handleKeydown)
} else {
document.removeEventListener('keydown', handleKeydown)
}
},
)
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// --- Preview logic (uses activeDoc) ---
const previewType = computed(() => getPreviewType(activeDoc.value))
const documentDescription = computed(() => describeDocument(activeDoc.value))
const documentSrc = computed(() => activeDoc.value?.fileUrl || activeDoc.value?.path || '')
const textContent = ref('')
const textLoading = ref(false)
const textError = ref('')
watch(
activeDoc,
async (doc) => {
textContent.value = ''
textError.value = ''
textLoading.value = false
if (!doc) { return }
if (getPreviewType(doc) !== 'text') { return }
try {
textLoading.value = true
const url = doc.fileUrl || doc.path || ''
if (!url) {
textError.value = 'Aucune URL de document disponible.'
return
}
const response = await fetch(url, { credentials: 'include' })
if (!response.ok) {
throw new Error('Téléchargement du document impossible')
}
textContent.value = await response.text()
} catch (error) {
console.error('Erreur lors du chargement du texte:', error)
textError.value = error.message || 'Impossible de lire ce document.'
} finally {
textLoading.value = false
}
},
{ immediate: true },
)
const close = () => {
emit('close')
}
const download = () => {
const url = activeDoc.value?.downloadUrl || activeDoc.value?.fileUrl || activeDoc.value?.path
if (!url) { return }
window.open(url, '_blank')
}
</script>

View File

@@ -0,0 +1,105 @@
<template>
<div
v-if="document"
class="flex items-center justify-center overflow-hidden rounded-md border border-base-200 bg-base-200/70"
:class="thumbnailClass"
>
<img
v-if="canRenderImage"
:src="previewSrc"
class="h-full w-full object-cover"
:alt="altText"
loading="lazy"
decoding="async"
>
<component
v-else
:is="icon.component"
class="h-6 w-6"
:class="icon.colorClass"
aria-hidden="true"
/>
</div>
<div
v-else
class="flex h-16 w-16 items-center justify-center rounded-md border border-dashed border-base-200 bg-base-200/40 text-xs text-base-content/40"
aria-hidden="true"
>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { getFileIcon } from '~/utils/fileIcons';
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview';
type GenericDocument = {
id?: string | number;
name?: string | null;
filename?: string | null;
mimeType?: string | null;
path?: string | null;
fileUrl?: string | null;
downloadUrl?: string | null;
size?: number | null;
};
const props = defineProps<{
document: GenericDocument | null | undefined;
alt?: string;
}>();
const normalizedDocument = computed(() => props.document ?? null);
const canRenderImage = computed(() => {
const doc = normalizedDocument.value;
return !!(doc && isImageDocument(doc) && (doc.fileUrl || doc.path));
});
const canRenderPdf = computed(() => {
// Rendering many PDF iframes in a list is very heavy for the browser.
// We intentionally disable inline PDF previews and fall back to an icon.
return false;
});
const appendPdfViewerParams = (src: string) => {
if (!src || src.startsWith('data:')) {
return src;
}
if (src.includes('#')) {
return `${src}&toolbar=0&navpanes=0`;
}
return `${src}#toolbar=0&navpanes=0`;
};
const previewSrc = computed(() => {
const doc = normalizedDocument.value;
const url = doc?.fileUrl || doc?.path;
if (!doc || !url) {
return '';
}
if (isPdfDocument(doc)) {
return appendPdfViewerParams(url);
}
return url;
});
const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16'));
const icon = computed(() => {
const doc = normalizedDocument.value;
return getFileIcon({
name: doc?.filename || doc?.name || '',
mime: doc?.mimeType || undefined,
});
});
const altText = computed(() => {
if (props.alt) {
return props.alt;
}
const doc = normalizedDocument.value;
return doc?.name ? `Aperçu de ${doc.name}` : 'Aperçu du document';
});
</script>

View File

@@ -0,0 +1,245 @@
<template>
<div
class="border-2 border-dashed rounded-lg p-6 transition-colors"
:class="dragActive ? 'border-primary bg-primary/5' : 'border-base-300 bg-base-100'"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<div class="flex flex-col items-center gap-3 text-center">
<IconLucideCloudUpload class="w-10 h-10 text-primary" aria-hidden="true" />
<div>
<h3 class="font-semibold">
{{ title }}
</h3>
<p class="text-sm text-base-content/50">
{{ subtitle }}
</p>
</div>
<div class="flex flex-wrap justify-center gap-2">
<button type="button" class="btn btn-primary btn-sm" @click="triggerFileDialog">
Sélectionner des fichiers
</button>
<span class="text-xs text-base-content/50">ou glisser-déposer ici</span>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
:accept="accept"
:multiple="multiple"
@change="onFileChange"
>
<div class="w-full max-w-xs mt-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70">
Type de document
</label>
<select
class="select select-bordered select-sm w-full mt-1"
:value="documentType"
@change="emit('update:documentType', $event.target.value)"
>
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</div>
<ul v-if="selectedFiles.length" class="mt-4 w-full space-y-2 text-left">
<li v-for="file in selectedFiles" :key="file.name" class="flex items-center justify-between text-sm">
<div class="flex items-center gap-3">
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-300 bg-base-200/70 flex items-center justify-center">
<img
v-if="isImageFile(file)"
:src="getFilePreview(file)"
class="h-full w-full object-cover"
:alt="`Aperçu de ${file.name}`"
>
<component
v-else
:is="getIcon(file).component"
class="h-6 w-6"
:class="getIcon(file).colorClass"
aria-hidden="true"
/>
</div>
<div class="flex flex-col">
<span class="font-medium">{{ file.name }}</span>
<span class="text-xs text-base-content/50">{{ formatSize(file.size) }} {{ file.type || 'Type inconnu' }}</span>
</div>
</div>
<button type="button" class="btn btn-ghost btn-xs" @click="removeFile(file)">
Retirer
</button>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useToast } from '~/composables/useToast'
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
import { getFileIcon } from '~/utils/fileIcons'
import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
const props = defineProps({
title: {
type: String,
default: 'Ajouter des documents'
},
subtitle: {
type: String,
default: 'Formats acceptés : PDF, images, textes…'
},
accept: {
type: String,
default: ''
},
multiple: {
type: Boolean,
default: true
},
modelValue: {
type: Array,
default: () => []
},
maxFileSizeMb: {
type: Number,
default: 200
},
documentType: {
type: String,
default: 'documentation'
}
})
const emit = defineEmits(['update:modelValue', 'files-added', 'update:documentType'])
const dragActive = ref(false)
const fileInput = ref(null)
const internalFiles = ref([])
const { showError } = useToast()
const previewUrls = new Map()
const isImageFile = (file) => (file?.type || '').startsWith('image/')
const getFilePreview = (file) => {
if (!isImageFile(file)) { return null }
if (!previewUrls.has(file)) {
previewUrls.set(file, URL.createObjectURL(file))
}
return previewUrls.get(file)
}
const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
const nextSet = new Set(nextFiles)
previousFiles.forEach((file) => {
if (!nextSet.has(file)) {
const url = previewUrls.get(file)
if (url) {
URL.revokeObjectURL(url)
previewUrls.delete(file)
}
}
})
}
const selectedFiles = internalFiles
watch(
() => props.modelValue,
(newValue) => {
if (Array.isArray(newValue)) {
cleanupRemovedPreviews(internalFiles.value, newValue)
internalFiles.value = [...newValue]
}
},
{ immediate: true }
)
const triggerFileDialog = () => {
fileInput.value?.click()
}
const emitFiles = (files) => {
cleanupRemovedPreviews(internalFiles.value, files)
internalFiles.value = files
emit('update:modelValue', files)
emit('files-added', files)
}
const handleFiles = (fileList) => {
const files = Array.from(fileList)
const maxBytes = props.maxFileSizeMb * 1024 * 1024
if (!props.multiple) {
const validFile = files[0]
if (validFile && validFile.size > maxBytes) {
showError(`Le fichier "${validFile.name}" dépasse la limite de ${props.maxFileSizeMb} Mo`)
return
}
emitFiles(files.slice(0, 1))
} else {
const merged = [...internalFiles.value]
files.forEach((file) => {
if (file.size > maxBytes) {
showError(`Le fichier "${file.name}" dépasse la limite de ${props.maxFileSizeMb} Mo`)
return
}
if (!merged.some(existing => existing.name === file.name && existing.size === file.size)) {
merged.push(file)
}
})
emitFiles(merged)
}
}
const onFileChange = (event) => {
handleFiles(event.target.files || [])
event.target.value = ''
}
const onDragOver = () => {
dragActive.value = true
}
const onDragLeave = () => {
dragActive.value = false
}
const onDrop = (event) => {
dragActive.value = false
if (event.dataTransfer?.files?.length) {
handleFiles(event.dataTransfer.files)
}
}
const removeFile = (fileToRemove) => {
const filtered = internalFiles.value.filter(file => file !== fileToRemove)
emitFiles(filtered)
}
const formatSize = (size) => {
if (!size) { return '0 B' }
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.floor(Math.log(size) / Math.log(1024))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
const getIcon = (file) => {
return getFileIcon({ name: file.name, mime: file.type })
}
onBeforeUnmount(() => {
previewUrls.forEach((url) => {
URL.revokeObjectURL(url)
})
previewUrls.clear()
})
</script>

View File

@@ -0,0 +1,138 @@
<template>
<dialog class="modal" :class="{ 'modal-open': open }">
<div class="modal-box max-w-3xl">
<form method="dialog" class="modal-close" @submit.prevent />
<h3 class="font-bold text-lg mb-2">
Préparer l'impression
</h3>
<p class="text-sm text-base-content/70 mb-4">
Choisissez les sections à inclure avant de lancer l'impression.
</p>
<div class="flex flex-wrap gap-2 mb-4">
<button type="button" class="btn btn-xs btn-outline" @click="emit('select-all')">
Tout sélectionner
</button>
<button type="button" class="btn btn-xs btn-outline" @click="emit('deselect-all')">
Tout désélectionner
</button>
</div>
<div class="max-h-[420px] overflow-y-auto pr-2 space-y-6">
<section class="bg-base-200/50 rounded-xl p-4 space-y-3">
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
Machine
</h4>
<label class="flex items-start gap-3">
<input
v-model="selection.machine.info"
type="checkbox"
class="checkbox checkbox-primary mt-1"
>
<div>
<p class="font-medium">Informations générales</p>
<p class="text-xs text-base-content/60">
Nom, site et fournisseur de la machine.
</p>
</div>
</label>
<label class="flex items-start gap-3">
<input
v-model="selection.machine.customFields"
type="checkbox"
class="checkbox checkbox-primary mt-1"
>
<div>
<p class="font-medium">Champs personnalisés</p>
<p class="text-xs text-base-content/60">
Valeurs spécifiques configurées pour cette machine.
</p>
</div>
</label>
<label class="flex items-start gap-3">
<input
v-model="selection.machine.documents"
type="checkbox"
class="checkbox checkbox-primary mt-1"
>
<div>
<p class="font-medium">Documents</p>
<p class="text-xs text-base-content/60">
Pièces jointes liées directement à la machine.
</p>
</div>
</label>
</section>
<section v-if="hasComponents" class="bg-base-200/30 rounded-xl p-4 space-y-3">
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
Composants & pièces
</h4>
<div class="space-y-3">
<MachinePrintSelectionNode
v-for="component in componentsList"
:key="component.id"
:component="component"
:selection="selection"
/>
</div>
</section>
<section v-if="hasPieces" class="bg-base-200/30 rounded-xl p-4 space-y-3">
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
Pièces indépendantes
</h4>
<div class="space-y-2">
<label
v-for="piece in piecesList"
:key="piece.id"
class="flex items-start gap-3"
>
<input
v-model="selection.pieces[piece.id]"
type="checkbox"
class="checkbox checkbox-secondary mt-1"
>
<div>
<p class="font-medium">{{ piece.name }}</p>
<p class="text-xs text-base-content/60">
{{ piece.reference || 'Référence inconnue' }}
</p>
</div>
</label>
</div>
</section>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="emit('close')">
Annuler
</button>
<button type="button" class="btn btn-primary" @click="emit('confirm')">
Imprimer
</button>
</div>
</div>
</dialog>
</template>
<script setup>
import { computed, toRef } from 'vue'
import MachinePrintSelectionNode from '~/components/MachinePrintSelectionNode.vue'
const props = defineProps({
open: { type: Boolean, default: false },
selection: { type: Object, required: true },
components: { type: Array, default: () => [] },
pieces: { type: Array, default: () => [] }
})
const emit = defineEmits(['close', 'confirm', 'select-all', 'deselect-all'])
const selection = toRef(props, 'selection')
const componentsList = computed(() => props.components || [])
const piecesList = computed(() => props.pieces || [])
const hasComponents = computed(() => componentsList.value.length > 0)
const hasPieces = computed(() => piecesList.value.length > 0)
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="rounded-lg border border-base-300 bg-base-100/80 p-3 space-y-3">
<label class="flex items-start gap-3">
<input
v-model="selection.components[component.id]"
type="checkbox"
class="checkbox checkbox-primary mt-1"
>
<div class="flex-1">
<p class="font-medium">{{ component.name }}</p>
<p v-if="component.reference" class="text-xs text-base-content/60">
{{ component.reference }}
</p>
</div>
</label>
<div v-if="childPieces.length" class="pl-6 space-y-2">
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">
Pièces
</p>
<label
v-for="piece in childPieces"
:key="piece.id"
class="flex items-start gap-3"
>
<input
v-model="selection.pieces[piece.id]"
type="checkbox"
class="checkbox checkbox-secondary mt-1"
>
<div>
<p class="font-medium">{{ piece.name }}</p>
<p class="text-xs text-base-content/60">
{{ piece.reference || 'Référence inconnue' }}
</p>
</div>
</label>
</div>
<div v-if="childComponents.length" class="pl-6 space-y-3 border-l border-base-200">
<MachinePrintSelectionNode
v-for="child in childComponents"
:key="child.id"
:component="child"
:selection="selection"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
defineOptions({ name: 'MachinePrintSelectionNode' })
const props = defineProps({
component: { type: Object, required: true },
selection: { type: Object, required: true }
})
const childComponents = computed(() => props.component.subcomponents || props.component.subComponents || [])
const childPieces = computed(() => props.component.pieces || [])
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-2 text-sm text-base-content/60">
<span v-if="stats.customFields" class="badge badge-outline badge-sm">{{ stats.customFields }} champ(s)</span>
<span v-if="stats.pieces" class="badge badge-outline badge-sm">{{ stats.pieces }} pièce(s)</span>
<span v-if="stats.subcomponents" class="badge badge-outline badge-sm">{{ stats.subcomponents }} sous-composant(s)</span>
<span v-if="!stats.customFields && !stats.pieces && !stats.subcomponents" class="text-xs text-base-content/50">
Structure vide
</span>
</div>
<details class="collapse collapse-arrow bg-base-200">
<summary class="collapse-title text-sm font-medium">
Voir la structure JSON
</summary>
<div class="collapse-content">
<pre class="mockup-code whitespace-pre-wrap text-xs bg-base-300 p-4 rounded">
<code>{{ formatted }}</code>
</pre>
</div>
</details>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computeStructureStats } from '~/shared/modelUtils'
const props = defineProps({
structure: {
type: Object,
default: () => ({})
}
})
const stats = computed(() => computeStructureStats(props.structure))
const formatted = computed(() => JSON.stringify(props.structure ?? {}, null, 2))
</script>

View File

@@ -0,0 +1,81 @@
<template>
<section :class="sectionClasses">
<div :class="contentClasses">
<div :class="['space-y-3', maxWidthClass]">
<component :is="headingTag" v-if="title" class="text-4xl font-bold tracking-tight">
{{ title }}
</component>
<p v-if="subtitle" class="text-sm opacity-80 leading-relaxed">
{{ subtitle }}
</p>
<slot />
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
title: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
},
gradientFrom: {
type: String,
default: 'from-primary'
},
gradientTo: {
type: String,
default: 'to-secondary'
},
minHeight: {
type: String,
default: 'min-h-[25vh]'
},
maxWidth: {
type: String,
default: 'max-w-xl'
},
rounded: {
type: Boolean,
default: false
},
alignment: {
type: String,
default: 'center',
validator: value => ['center', 'start', 'end'].includes(value)
},
headingTag: {
type: String,
default: 'h1'
}
})
const sectionClasses = computed(() => {
const classes = ['hero', 'bg-gradient-to-br', props.gradientFrom, props.gradientTo, props.minHeight]
if (props.rounded) {
classes.push('rounded-xl', 'overflow-hidden')
}
return classes
})
const contentClasses = computed(() => {
const base = ['hero-content', 'text-neutral-content']
if (props.alignment === 'center') {
base.push('text-center')
} else if (props.alignment === 'start') {
base.push('justify-start', 'text-left')
} else if (props.alignment === 'end') {
base.push('justify-end', 'text-right')
}
return base
})
const maxWidthClass = computed(() => props.maxWidth)
</script>

View File

@@ -0,0 +1,700 @@
<template>
<div class="space-y-4">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
<div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot || piece.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'">
<div class="flex items-start gap-3 flex-1 min-w-0">
<button
type="button"
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
:class="{ 'rotate-90': !isCollapsed }"
:aria-expanded="!isCollapsed"
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
@click="toggleCollapse"
>
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
</button>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
<NuxtLink
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
:to="machineId
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
: `/piece/${piece.pieceId}`"
class="hover: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"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors ml-1"
title="Cliquer pour associer un item"
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
>
À remplir
</button>
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
</h3>
<div class="flex flex-wrap gap-2 mt-2">
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
Rattachée à {{ piece.parentComponentName }}
</span>
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
<template v-if="pieceConstructeursDisplay.length">
<span
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="badge badge-outline badge-sm"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
({{ supplierReferenceMap.get(constructeur.id) }})
</span>
</span>
</template>
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div>
</div>
</div>
<button
v-if="showDelete"
type="button"
class="btn btn-ghost btn-xs text-error shrink-0"
title="Supprimer cette pièce"
@click="$emit('delete')"
>
Supprimer
</button>
</div>
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4">
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<div class="space-y-2 text-sm">
<div v-if="isEditMode" class="form-control">
<label class="label">
<span class="label-text text-sm">Quantité</span>
</label>
<input
v-model.number="pieceData.quantity"
type="number"
min="1"
step="1"
class="input input-bordered input-sm md:input-md w-24"
@blur="updatePiece"
/>
</div>
<div v-else-if="displayQuantity > 1">
<span class="font-medium">Quantité:</span>
<span class="ml-2">{{ displayQuantity }}</span>
</div>
<div>
<span class="font-medium">Référence:</span>
<input
v-if="isEditMode"
:id="`piece-reference-${piece.id}`"
v-model="pieceData.reference"
type="text"
class="input input-sm input-bordered ml-2"
@blur="updatePiece"
/>
<span v-else class="ml-2">{{
pieceData.reference || "Non définie"
}}</span>
</div>
<div v-if="pieceData.referenceAuto">
<span class="font-medium">Référence auto:</span>
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
</div>
<div>
<span class="font-medium">Fournisseur:</span>
<div v-if="!isEditMode" class="ml-2">
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
<div
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="flex flex-col"
>
<span class="font-medium">
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
</span>
<span
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-base-content/50"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div>
</div>
<span v-else class="font-medium">
Non défini
</span>
</div>
<ConstructeurSelect
v-else
class="w-full"
:model-value="pieceConstructeurIds"
:initial-options="pieceConstructeursDisplay"
placeholder="Sélectionner un ou plusieurs fournisseurs..."
@update:model-value="handleConstructeurChange"
/>
</div>
<div>
<span class="font-medium">Prix:</span>
<input
v-if="isEditMode"
:id="`piece-prix-${piece.id}`"
v-model="pieceData.prix"
type="number"
step="0.01"
class="input input-sm input-bordered ml-2"
@blur="updatePiece"
/>
<span v-else class="ml-2">{{
pieceData.prix ? `${pieceData.prix}` : "Non défini"
}}</span>
</div>
<div>
<span class="font-medium">Produit catalogue:</span>
<div v-if="isEditMode" class="mt-2 space-y-2">
<ProductSelect
:model-value="pieceData.productId"
placeholder="Associer un produit…"
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
@update:modelValue="handleProductChange"
/>
<div
v-if="selectedProduct"
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
>
<p class="text-sm font-semibold text-base-content">
{{ selectedProduct.name }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="flex flex-wrap gap-1"
>
<span class="font-semibold">{{ info.label }} :</span>
<span>{{ info.value }}</span>
</p>
<NuxtLink
v-if="selectedProduct.id"
:to="`/product/${selectedProduct.id}`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</div>
<p v-else class="text-xs text-base-content/60">
Aucun produit associé.
</p>
</div>
<div class="ml-2">
<div v-if="displayProduct" class="space-y-1">
<p class="font-medium text-base-content">
{{ displayProductName || 'Produit catalogue' }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/70"
>
<span class="font-semibold">{{ info.label }} :</span>
<span class="ml-1">{{ info.value }}</span>
</p>
<ProductDocumentsInline
:documents="productDocuments"
@preview="openPreview"
/>
</div>
<span v-else class="font-medium">
Non défini
</span>
</div>
</div>
</div>
</div>
<!-- Champs personnalisés de la pièce -->
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
title="Champs personnalisés item"
:editable="false"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
<template v-if="mergedContextFields.length">
<div class="divider my-4 text-xs text-base-content/50">
Champs personnalisés machine
</div>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
<span
v-if="isEditMode && selectedFiles.length"
class="badge badge-outline"
>
{{ selectedFiles.length }} fichier{{
selectedFiles.length > 1 ? "s" : ""
}}
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
</span>
</div>
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
Chargement des documents...
</p>
<DocumentUpload
v-if="isEditMode"
v-model="selectedFiles"
title="Déposer des fichiers pour cette pièce"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded"
/>
<DocumentListInline
:documents="pieceDocuments"
:can-delete="isEditMode"
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à cette pièce."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, watch, computed } from 'vue'
import ConstructeurSelect from './ConstructeurSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProducts } from '~/composables/useProducts'
import {
formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs,
uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils'
import { mergeDefinitionsWithValues } from '~/shared/utils/customFields'
import { useCustomFields } from '~/composables/useCustomFields'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
const route = useRoute()
const machineId = computed(() => route.params.id as string | undefined)
const props = defineProps({
piece: { type: Object, required: true },
isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 },
})
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete', 'fill-entity'])
// --- Local reactive data for editing ---
const pieceData = reactive({
name: props.piece.name || '',
reference: props.piece.reference || '',
referenceAuto: props.piece.referenceAuto || null,
prix: props.piece.prix || '',
productId: props.piece.product?.id || props.piece.productId || null,
quantity: props.piece.quantity ?? 1,
})
const displayQuantity = computed(() => {
return pieceData.quantity ?? 1
})
// --- Products ---
const { products, loadProducts, getProduct } = useProducts()
const selectedProduct = computed(() => {
const id = pieceData.productId
if (!id) return null
const list = Array.isArray(products.value) ? products.value : []
const cached = list.find((p) => p && p.id === id) || null
if (cached) return cached
const current = props.piece.product
if (current && current.id === id) return current
return null
})
// --- Shared composables ---
const {
documents: pieceDocuments,
selectedFiles,
uploadingDocuments,
loadingDocuments,
previewDocument,
previewVisible,
openPreview,
closePreview,
refreshDocuments,
handleFilesAdded,
removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
const {
displayProduct,
displayProductName,
productInfoRows,
productDocuments,
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
const {
updateCustomFieldValue: updateCustomFieldValueApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
// Parent already pre-merges standalone custom fields into props.piece.customFields
const displayedCustomFields = computed(() => {
const fields = props.piece?.customFields
return Array.isArray(fields) ? fields.filter((f) => !f.machineContextOnly) : []
})
const updateCustomField = async (field) => {
if (!field || field.readOnly) return
const e = props.piece
const fieldValueId = field.customFieldValueId
if (fieldValueId) {
const result = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
showSuccess(`Champ "${field.name}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${field.name}"`)
}
return
}
if (!e?.id) {
showError('Impossible de créer la valeur pour ce champ')
return
}
const metadata = field.customFieldId ? undefined : {
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
}
const result = await upsertCustomFieldValue(
field.customFieldId,
'piece',
e.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
}
}
showSuccess(`Champ "${field.name}" créé avec succès`)
} else {
showError(`Erreur lors de la sauvegarde du champ "${field.name}"`)
}
}
// Context fields are NOT pre-merged — merge locally
const mergedContextFields = computed(() => {
const definitions = props.piece?.contextCustomFields ?? []
const values = props.piece?.contextCustomFieldValues ?? []
if (!definitions.length && !values.length) return []
return mergeDefinitionsWithValues(definitions, values)
})
const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.piece?.linkId
if (!linkId || !field) return
const customFieldId = field.customFieldId
const customFieldValueId = field.customFieldValueId
if (!customFieldId && !customFieldValueId) return
field.value = value
emit('custom-field-update', {
entityType: 'machinePieceLink',
entityId: linkId,
fieldId: customFieldId,
customFieldValueId,
value: value ?? '',
fieldName: field.name || 'Champ contextuel',
})
}
// --- Document edit modal ---
const editingDocument = ref(null)
const editModalVisible = ref(false)
const openEditModal = (doc) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data) => {
if (!editingDocument.value?.id) return
await editDocument(editingDocument.value.id, data)
editModalVisible.value = false
editingDocument.value = null
}
// --- Collapse state ---
const isCollapsed = ref(true)
watch(
() => props.toggleToken,
() => {
isCollapsed.value = props.collapseAll
if (!isCollapsed.value) refreshDocuments()
},
{ immediate: true },
)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
if (!isCollapsed.value) refreshDocuments()
}
// --- Constructeurs ---
const { constructeurs } = useConstructeurs()
const pieceConstructeurLinks = computed(() =>
parseConstructeurLinksFromApi(
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
),
)
const supplierReferenceMap = computed(() => {
const map = new Map()
pieceConstructeurLinks.value.forEach(l => {
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
})
return map
})
const pieceConstructeurIds = computed(() =>
pieceConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
)
const pieceConstructeursDisplay = computed(() => {
// Extract nested constructeur objects from link entries
const linkConstructeurs = pieceConstructeurLinks.value
.filter(l => l.constructeur && l.constructeur.id)
.map(l => l.constructeur)
return resolveConstructeurs(
pieceConstructeurIds.value,
linkConstructeurs,
constructeurs.value,
)
})
const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur)
const handleConstructeurChange = (value) => {
const ids = uniqueConstructeurIds(value)
props.piece.constructeurIds = [...ids]
props.piece.constructeurId = null
props.piece.constructeur = null
props.piece.constructeurs = resolveConstructeurs(
ids,
constructeurs.value,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
)
updatePiece()
}
// --- Product handling ---
const ensureProductLoaded = async (id) => {
if (!id) return null
const list = Array.isArray(products.value) ? products.value : []
const cached = list.find((p) => p && p.id === id)
if (cached) return cached
const result = await getProduct(id, { force: true })
return result.success && result.data ? result.data : null
}
const handleProductChange = async (value) => {
const nextId = value || null
pieceData.productId = nextId
props.piece.productId = nextId
if (!nextId) {
props.piece.product = null
updatePiece()
return
}
const resolved = await ensureProductLoaded(nextId)
if (resolved) {
props.piece.product = resolved
const supplierPrice = resolved.supplierPrice
if (
(pieceData.prix === '' || pieceData.prix === null || pieceData.prix === undefined) &&
supplierPrice !== null && supplierPrice !== undefined
) {
const number = Number(supplierPrice)
if (!Number.isNaN(number)) pieceData.prix = String(number)
}
}
updatePiece()
}
// --- Custom field event handlers ---
const handleCustomFieldInput = (field, value) => {
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
}
const handleCustomFieldBlur = async (field) => {
await updateCustomField(field)
const cfId = field?.customFieldId || null
if (cfId || field?.customFieldValueId) {
emit('custom-field-update', {
fieldId: cfId,
pieceId: props.piece.id,
value: field?.value ?? '',
})
}
}
// --- Update piece ---
const updatePiece = () => {
const prixValue = pieceData.prix
let parsedPrice = null
if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) {
const numeric = Number(prixValue)
if (!Number.isNaN(numeric)) parsedPrice = String(numeric)
}
const product = selectedProduct.value ? { ...selectedProduct.value } : null
emit('update', {
...props.piece,
...pieceData,
prix: parsedPrice,
quantity: pieceData.quantity ?? 1,
productId: pieceData.productId || null,
product,
constructeurIds: pieceConstructeurIds.value,
})
}
// --- Watchers ---
watch(
() => props.piece.product?.id || props.piece.productId || null,
async (id) => {
if (pieceData.productId === id) {
if (id && !selectedProduct.value) {
const resolved = await ensureProductLoaded(id)
if (resolved) props.piece.product = resolved
}
if (!id) props.piece.product = null
return
}
pieceData.productId = id
if (id) {
const resolved = await ensureProductLoaded(id)
if (resolved) {
props.piece.product = resolved
if (
(pieceData.prix === '' || pieceData.prix === null || pieceData.prix === undefined) &&
resolved.supplierPrice !== null && resolved.supplierPrice !== undefined
) {
const number = Number(resolved.supplierPrice)
if (!Number.isNaN(number)) pieceData.prix = String(number)
}
}
} else {
props.piece.product = null
}
},
{ immediate: true },
)
watch(
() => [props.piece.name, props.piece.reference, props.piece.prix, props.piece.quantity],
() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
},
)
onMounted(() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments()
})
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div class="space-y-6">
<section class="space-y-3">
<header>
<h3 class="text-sm font-semibold">
Produits inclus par défaut
</h3>
<p class="text-xs text-base-content/70">
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
</p>
</header>
<p v-if="!products.length" class="text-xs text-base-content/50">
Aucun produit défini.
</p>
<ul v-else class="space-y-2" role="list">
<li
v-for="(product, index) in products"
:key="product.uid"
class="space-y-3 rounded-md border border-base-200 bg-base-100 p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 space-y-3">
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Famille de produit</span>
</label>
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
@change="handleProductTypeSelect(product)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in productTypeOptions"
:key="type.id"
:value="type.id"
>
{{ formatProductTypeOption(type) }}
</option>
</select>
</div>
</div>
<button
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeProduct(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</li>
</ul>
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section class="space-y-3">
<h3 class="text-sm font-semibold">
Champs personnalisés
</h3>
<p v-if="!fields.length" class="text-xs text-base-content/50">
Aucun champ personnalisé n'a encore été défini.
</p>
<ul v-else class="space-y-2" role="list">
<li
v-for="(field, index) in fields"
:key="field.uid"
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
:class="reorderClass(index)"
draggable="true"
@dragstart="onDragStart(index, $event)"
@dragenter="onDragEnter(index)"
@dragover.prevent="onDragEnter(index)"
@drop.prevent="onDrop(index)"
@dragend="onDragEnd"
>
<div class="flex items-start gap-3">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
title="Réordonner"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
Obligatoire
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
/>
</div>
<button
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</li>
</ul>
<button type="button" class="btn btn-outline btn-xs" @click="addField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
</div>
</template>
<script setup lang="ts">
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
import type { PieceModelStructure } from '~/shared/types/inventory'
import { usePieceStructureEditorLogic } from '~/composables/usePieceStructureEditorLogic'
defineOptions({ name: 'PieceModelStructureEditor' })
const props = defineProps<{
modelValue?: PieceModelStructure | null
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: PieceModelStructure): void
}>()
const {
fields,
products,
productTypeOptions,
formatProductTypeOption,
handleProductTypeSelect,
addProduct,
removeProduct,
addField,
removeField,
reorderClass,
onDragStart,
onDragEnter,
onDrop,
onDragEnd,
} = usePieceStructureEditorLogic({ props, emit })
</script>

View File

@@ -0,0 +1,130 @@
<template>
<div class="space-y-1">
<SearchSelect
:model-value="modelValue ?? undefined"
:options="pieceOptions"
:loading="loading"
:placeholder="placeholder"
:empty-text="emptyText"
size="sm"
option-value="id"
:option-label="formatLabel"
:disabled="disabled"
server-search
@update:modelValue="updateValue"
@search="handleSearch"
>
<template #option-description="{ option }">
<span class="text-xs text-base-content/60">
{{ formatDescription(option) }}
</span>
</template>
</SearchSelect>
<p v-if="helperText" class="text-xs text-base-content/60">
{{ helperText }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieces } from '~/composables/usePieces'
const props = withDefaults(
defineProps<{
modelValue?: string | null
placeholder?: string
emptyText?: string
helperText?: string
disabled?: boolean
typePieceId?: string | null
}>(),
{
modelValue: '',
placeholder: 'Sélectionner une pièce…',
emptyText: 'Aucune pièce disponible',
helperText: '',
disabled: false,
typePieceId: null,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const { loading: globalLoading, loadPieces } = usePieces()
const localPieces = ref<any[]>([])
const localLoading = ref(false)
const loading = computed(() => localLoading.value || globalLoading.value)
const pieceOptions = computed(() => localPieces.value)
const loadFilteredPieces = async (search = '') => {
if (!props.typePieceId) return
localLoading.value = true
try {
const result = await loadPieces({ typePieceId: props.typePieceId, search, itemsPerPage: 200, force: true })
if (result.success && result.data?.items) {
localPieces.value = result.data.items
}
}
catch (error: unknown) {
console.error('Erreur lors du chargement des pièces:', error)
}
finally {
localLoading.value = false
}
}
let searchDebounce: ReturnType<typeof setTimeout> | null = null
const handleSearch = (term: string) => {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => loadFilteredPieces(term.trim()), 300)
}
onMounted(() => {
loadFilteredPieces()
})
watch(
() => props.typePieceId,
() => {
loadFilteredPieces()
},
)
const updateValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === '') {
emit('update:modelValue', null)
return
}
emit('update:modelValue', String(value))
}
const formatLabel = (option: any) => {
if (!option) return ''
const name = option.name || 'Pièce'
return option.reference ? `${name}${option.reference}` : name
}
const formatDescription = (option: any) => {
const parts: string[] = []
const typeName = option?.typePiece?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) {
parts.push(`Ref. ${option.reference}`)
}
if (option?.prix !== undefined && option.prix !== null) {
const price = Number(option.prix)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.length ? parts.join(' • ') : 'Sans référence'
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div
v-if="documents.length"
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
>
<h5 class="font-medium text-base-content">Documents du produit</h5>
<div
v-for="document in documents"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-5 w-5"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">
{{ document.name }}
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="$emit('preview', document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</template>
<script setup>
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
defineProps({
documents: { type: Array, required: true },
})
defineEmits(['preview'])
</script>

View File

@@ -0,0 +1,130 @@
<template>
<div class="space-y-1">
<SearchSelect
:model-value="modelValue ?? undefined"
:options="productOptions"
:loading="loading"
:placeholder="placeholder"
:empty-text="emptyText"
size="sm"
option-value="id"
:option-label="formatLabel"
:disabled="disabled"
server-search
@update:modelValue="updateValue"
@search="handleSearch"
>
<template #option-description="{ option }">
<span class="text-xs text-base-content/60">
{{ formatDescription(option) }}
</span>
</template>
</SearchSelect>
<p v-if="helperText" class="text-xs text-base-content/60">
{{ helperText }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { useProducts } from '~/composables/useProducts'
const props = withDefaults(
defineProps<{
modelValue?: string | null
placeholder?: string
emptyText?: string
helperText?: string
disabled?: boolean
typeProductId?: string | null
}>(),
{
modelValue: '',
placeholder: 'Sélectionner un produit…',
emptyText: 'Aucun produit disponible',
helperText: '',
disabled: false,
typeProductId: null,
},
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const { loading: globalLoading, loadProducts } = useProducts()
const localProducts = ref<any[]>([])
const localLoading = ref(false)
const loading = computed(() => localLoading.value || globalLoading.value)
const productOptions = computed(() => localProducts.value)
const loadFilteredProducts = async (search = '') => {
if (!props.typeProductId) return
localLoading.value = true
try {
const result = await loadProducts({ typeProductId: props.typeProductId, search, itemsPerPage: 200, force: true })
if (result.success && result.data?.items) {
localProducts.value = result.data.items
}
}
catch (error: unknown) {
console.error('Erreur lors du chargement des produits:', error)
}
finally {
localLoading.value = false
}
}
let searchDebounce: ReturnType<typeof setTimeout> | null = null
const handleSearch = (term: string) => {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => loadFilteredProducts(term.trim()), 300)
}
onMounted(() => {
loadFilteredProducts()
})
watch(
() => props.typeProductId,
() => {
loadFilteredProducts()
},
)
const updateValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === '') {
emit('update:modelValue', null)
return
}
emit('update:modelValue', String(value))
}
const formatLabel = (option: any) => {
if (!option) return ''
const name = option.name || 'Produit'
return option.reference ? `${name}${option.reference}` : name
}
const formatDescription = (option: any) => {
const parts: string[] = []
const typeName = option?.typeProduct?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) {
parts.push(`Ref. ${option.reference}`)
}
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
const price = Number(option.supplierPrice)
if (!Number.isNaN(price)) {
parts.push(`${price.toFixed(2)}`)
}
}
return parts.length ? parts.join(' • ') : 'Sans référence'
}
</script>

View File

@@ -0,0 +1,420 @@
<template>
<div :class="containerClass">
<div class="border border-base-200 rounded-lg bg-base-100 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-base-200 px-4 py-3">
<div class="flex-1 min-w-[220px] space-y-2">
<label class="label">
<span class="label-text text-xs font-semibold">
{{ isRoot ? 'Composant racine de la catégorie' : 'Famille de composant' }}
</span>
</label>
<template v-if="isRoot">
<p class="text-[11px] text-base-content/50">
Le composant racine correspond à la catégorie que vous éditez. Sélectionnez uniquement les familles pour les sous-composants.
</p>
</template>
<template v-else-if="!lockType">
<select
v-model="node.typeComposantId"
class="select select-bordered select-sm w-full"
:disabled="isLocked"
@change="handleComponentTypeSelect(node)"
>
<option value="">
Sélectionner une famille de composant
</option>
<option
v-for="type in componentTypes"
:key="type.id"
:value="type.id"
>
{{ formatComponentTypeOption(type) }}
</option>
</select>
<p class="text-[11px] text-base-content/50">
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p>
<div v-if="!isRoot" class="form-control mt-2">
<label class="label py-1">
<span class="label-text text-[11px]">Alias (optionnel)</span>
</label>
<input
v-model="node.alias"
type="text"
class="input input-bordered input-xs"
placeholder="Alias du sous-composant"
:disabled="isLocked"
/>
</div>
</template>
<template v-else>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ lockedTypeDisplay }}
</div>
</template>
</div>
<button
v-if="!isRoot && !isLocked"
type="button"
class="btn btn-error btn-xs btn-square"
@click="emit('remove')"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else-if="!isRoot && isLocked" class="tooltip tooltip-left" data-tip="Ce sous-composant ne peut pas être supprimé">
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
<div class="px-4 py-4 space-y-5">
<section v-if="isRoot" class="space-y-3">
<h4 :class="headingClass">
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
</h4>
<p v-if="!(node.customFields?.length)" class="text-xs text-base-content/50">
Aucun champ n'a encore été défini.
</p>
<div v-else class="space-y-2">
<div
v-for="(field, index) in node.customFields"
:key="`field-${index}`"
class="border border-base-200 rounded-md p-3 space-y-2 transition-colors"
:class="customFieldReorderClass(index)"
draggable="true"
@dragstart="onCustomFieldDragStart(index, $event)"
@dragenter="onCustomFieldDragEnter(index)"
@dragover.prevent
@drop="onCustomFieldDrop(index)"
@dragend="onCustomFieldDragEnd"
>
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
title="Réorganiser"
draggable="true"
@dragstart.stop="onCustomFieldDragStart(index, $event)"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
/>
<select v-model="field.type" class="select select-bordered select-xs">
<option value="text">Texte</option>
<option value="number">Nombre</option>
<option value="select">Liste</option>
<option value="boolean">Oui/Non</option>
<option value="date">Date</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
Obligatoire
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
Contexte machine uniquement
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
></textarea>
</div>
<button
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeCustomField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section v-if="isRoot" class="space-y-3">
<h4 :class="headingClass">
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
</h4>
<p v-if="!(node.products?.length)" class="text-xs text-base-content/50">
Aucun produit défini.
</p>
<div v-else class="space-y-2">
<div
v-for="(product, index) in node.products"
:key="`product-${index}`"
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
:class="productReorderClass(index)"
@dragenter="onProductDragEnter(index)"
@dragover="onProductDragOver"
@drop="onProductDrop(index)"
>
<button
type="button"
class="absolute left-2 top-3 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onProductDragStart(index, $event)"
@dragend="onProductDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">Famille de produit</span></label>
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
@change="handleProductTypeSelect(product)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in productTypes"
:key="type.id"
:value="type.id"
>
{{ formatProductTypeOption(type) }}
</option>
</select>
</div>
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section v-if="isRoot" class="space-y-3">
<h4 :class="headingClass">
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
</h4>
<p v-if="!(node.pieces?.length)" class="text-xs text-base-content/50">
Aucune pièce définie.
</p>
<div v-else class="space-y-2">
<div
v-for="(piece, index) in node.pieces"
:key="`piece-${index}`"
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
:class="pieceReorderClass(index)"
@dragenter="onPieceDragEnter(index)"
@dragover="onPieceDragOver"
@drop="onPieceDrop(index)"
>
<button
type="button"
class="absolute left-2 top-3 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onPieceDragStart(index, $event)"
@dragend="onPieceDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-3">
<div class="form-control">
<label class="label"><span class="label-text">Famille de pièce</span></label>
<div>
<select
v-model="piece.typePieceId"
class="select select-bordered select-xs"
@change="handlePieceTypeSelect(piece)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in pieceTypes"
:key="type.id"
:value="type.id"
>
{{ formatPieceTypeOption(type) }}
</option>
</select>
</div>
<p class="mt-1 text-[11px] text-base-content/50">
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
</p>
</div>
<!-- Quantity is set per-component on the component edit page -->
</div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
<h4 :class="headingClass">Sous-composants</h4>
<p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-base-content/50">
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
</p>
<p v-if="!hasSubcomponents" class="text-xs text-base-content/50">
Aucun sous-composant défini.
</p>
<div v-else class="space-y-3">
<div
v-for="(subComponent, index) in node.subcomponents"
:key="`sub-${index}`"
class="relative pl-8 transition-shadow rounded-lg"
:class="subcomponentReorderClass(index)"
@dragenter="onSubcomponentDragEnter(index)"
@dragover="onSubcomponentDragOver"
@drop="onSubcomponentDrop(index)"
>
<button
type="button"
class="absolute left-0 top-4 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onSubcomponentDragStart(index, $event)"
@dragend="onSubcomponentDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<StructureNodeEditor
:node="subComponent"
:depth="depth + 1"
:component-types="componentTypes"
:piece-types="pieceTypes"
:product-types="productTypes"
:allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth"
@remove="removeSubComponent(index)"
/>
</div>
</div>
<button
v-if="canManageSubcomponents"
type="button"
class="btn btn-outline btn-xs"
@click="addSubComponent"
>
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import type { EditableStructureNode, ModelTypeOption } from '~/composables/useStructureNodeLogic'
defineOptions({ name: 'StructureNodeEditor' })
const props = withDefaults(defineProps<{
node: EditableStructureNode
depth?: number
componentTypes?: ModelTypeOption[]
pieceTypes?: ModelTypeOption[]
productTypes?: ModelTypeOption[]
isRoot?: boolean
lockType?: boolean
lockedTypeLabel?: string
allowSubcomponents?: boolean
maxSubcomponentDepth?: number
isLocked?: boolean
}>(), {
depth: 0,
componentTypes: () => [],
pieceTypes: () => [],
productTypes: () => [],
isRoot: false,
lockType: false,
lockedTypeLabel: '',
allowSubcomponents: true,
maxSubcomponentDepth: Infinity,
isLocked: false,
})
const emit = defineEmits(['remove'])
const {
isLocked,
componentTypes,
pieceTypes,
productTypes,
canManageSubcomponents,
childAllowSubcomponents,
hasSubcomponents,
containerClass,
headingClass,
lockedTypeDisplay,
getComponentTypeLabel,
getPieceTypeLabel,
formatComponentTypeOption,
formatPieceTypeOption,
formatProductTypeOption,
handleComponentTypeSelect,
handlePieceTypeSelect,
handleProductTypeSelect,
addCustomField,
removeCustomField,
addPiece,
removePiece,
addProduct,
removeProduct,
addSubComponent,
removeSubComponent,
onCustomFieldDragStart,
onCustomFieldDragEnter,
onCustomFieldDrop,
onCustomFieldDragEnd,
customFieldReorderClass,
onPieceDragStart,
onPieceDragEnter,
onPieceDragOver,
onPieceDrop,
onPieceDragEnd,
pieceReorderClass,
onProductDragStart,
onProductDragEnter,
onProductDragOver,
onProductDrop,
onProductDragEnd,
productReorderClass,
onSubcomponentDragStart,
onSubcomponentDragEnter,
onSubcomponentDragOver,
onSubcomponentDrop,
onSubcomponentDragEnd,
subcomponentReorderClass,
} = useStructureNodeLogic(props)
</script>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { SyncPreviewResult } from '~/services/modelTypes';
const props = defineProps<{
preview: SyncPreviewResult | null;
open: boolean;
loading: boolean;
}>();
const emit = defineEmits<{
confirm: [];
cancel: [];
}>();
const dialogRef = ref<HTMLDialogElement>();
watch(() => props.open, (isOpen) => {
if (isOpen) {
dialogRef.value?.showModal();
}
else {
dialogRef.value?.close();
}
});
const hasDeletions = computed(() => {
if (!props.preview) return false;
return Object.values(props.preview.deletions).some(v => v > 0);
});
const hasModifications = computed(() => {
if (!props.preview) return false;
return Object.values(props.preview.modifications).some(v => v > 0);
});
const totalAdditions = computed(() => {
if (!props.preview) return 0;
return Object.values(props.preview.additions).reduce((sum, v) => sum + v, 0);
});
const totalDeletions = computed(() => {
if (!props.preview) return 0;
return Object.values(props.preview.deletions).reduce((sum, v) => sum + v, 0);
});
const totalModifications = computed(() => {
if (!props.preview) return 0;
return Object.values(props.preview.modifications).reduce((sum, v) => sum + v, 0);
});
function handleCancel() {
emit('cancel');
}
function handleConfirm() {
emit('confirm');
}
</script>
<template>
<dialog ref="dialogRef" class="modal" @close="handleCancel">
<div class="modal-box">
<h3 class="text-lg font-bold">
Synchronisation des éléments liés
</h3>
<div v-if="preview" class="py-4 space-y-3">
<p>
Cette modification impactera
<strong>{{ preview.itemCount }}</strong>
élément(s) lié(s).
</p>
<ul class="list-disc list-inside space-y-1">
<li v-if="totalAdditions > 0" class="text-success">
{{ totalAdditions }} ajout(s)
</li>
<li v-if="totalDeletions > 0" class="text-error">
{{ totalDeletions }} suppression(s)
</li>
<li v-if="totalModifications > 0" class="text-warning">
{{ totalModifications }} modification(s)
</li>
</ul>
<div v-if="hasDeletions" role="alert" class="alert alert-warning">
<span>Des éléments seront supprimés. Cette action est irréversible.</span>
</div>
<div v-if="hasModifications" role="alert" class="alert alert-info">
<span>Des valeurs de champs personnalisés seront réinitialisées.</span>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost" :disabled="loading" @click="handleCancel">
Annuler
</button>
<button class="btn btn-primary" :disabled="loading" @click="handleConfirm">
<span v-if="loading" class="loading loading-spinner loading-sm" />
Confirmer la synchronisation
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button @click="handleCancel">
close
</button>
</form>
</dialog>
</template>

View File

@@ -0,0 +1,126 @@
<template>
<div
class="toast-container pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2 items-end"
>
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast-item"
:class="[
'transform transition-all duration-300 ease-in-out',
toast.visible ? 'translate-y-0 opacity-100 pointer-events-auto' : 'translate-y-4 opacity-0 pointer-events-none'
]"
>
<div
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">
<!-- Icon -->
<div class="flex-shrink-0">
<IconLucideCheck
v-if="toast.type === 'success'"
class="w-4 h-4"
aria-hidden="true"
/>
<IconLucideCircleX
v-else-if="toast.type === 'error'"
class="w-4 h-4"
aria-hidden="true"
/>
<IconLucideAlertTriangle
v-else-if="toast.type === 'warning'"
class="w-4 h-4"
aria-hidden="true"
/>
<IconLucideInfo
v-else
class="w-4 h-4"
aria-hidden="true"
/>
</div>
<!-- Message -->
<div class="flex-1">
<span class="font-medium">{{ toast.message }}</span>
</div>
<!-- Close button -->
<button
class="btn btn-ghost btn-2xs"
@click="removeToast(toast.id)"
>
<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 lang="ts">
import { useToast } from '~/composables/useToast'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
import IconLucideCircleX from '~icons/lucide/circle-x'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
import IconLucideInfo from '~icons/lucide/info'
const { toasts, removeToast } = useToast()
const getToastClasses = (type: ToastType) => {
switch (type) {
case 'success':
return 'alert-success text-success-content'
case 'error':
return 'alert-error text-error-content'
case 'warning':
return 'alert-warning text-warning-content'
case 'info':
return 'alert-info text-info-content'
default:
return 'alert-info'
}
}
</script>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateY(16px);
}
.toast-leave-to {
opacity: 0;
transform: translateY(16px);
}
.toast-move {
transition: transform 0.3s ease;
}
.toast-card {
max-width: 20rem;
pointer-events: auto;
border-radius: 0.75rem;
}
@keyframes toast-progress {
from { width: 100%; }
to { width: 0%; }
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<teleport to="body">
<div
v-if="confirmState.open"
class="fixed inset-0 z-[1200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="handleCancel"
>
<div class="bg-base-100 rounded-box shadow-xl w-full max-w-md mx-4 p-6 space-y-4">
<h3 class="font-bold text-lg">
{{ confirmState.title }}
</h3>
<p class="whitespace-pre-line text-base-content/80">
{{ confirmState.message }}
</p>
<div class="flex justify-end gap-2 pt-2">
<button
class="btn btn-ghost btn-sm"
@click="handleCancel"
>
{{ confirmState.cancelText }}
</button>
<button
class="btn btn-sm"
:class="confirmState.dangerous ? 'btn-error' : 'btn-primary'"
@click="handleConfirm"
>
{{ confirmState.confirmText }}
</button>
</div>
</div>
</div>
</teleport>
</template>
<script setup lang="ts">
import { useConfirm } from '~/composables/useConfirm'
const { confirmState, handleConfirm, handleCancel } = useConfirm()
</script>

View File

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

View File

@@ -0,0 +1,83 @@
<template>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(field, index) in fields"
:key="fieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text">{{ field.name }}</span>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<input
v-if="field.type === 'text'"
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
<input
v-else-if="field.type === 'number'"
v-model="field.value"
type="number"
step="0.01"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
<select
v-else-if="field.type === 'select'"
v-model="field.value"
class="select select-bordered select-sm md:select-md"
:required="field.required"
:disabled="disabled"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input
v-model="field.value"
type="checkbox"
class="toggle toggle-primary toggle-sm md:toggle-md"
true-value="true"
false-value="false"
:disabled="disabled"
>
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
</label>
<input
v-else-if="field.type === 'date'"
v-model="field.value"
type="date"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
<input
v-else
v-model="field.value"
type="text"
class="input input-bordered input-sm md:input-md"
:required="field.required"
:disabled="disabled"
>
</div>
</div>
</template>
<script setup lang="ts">
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFields'
defineProps<{
fields: CustomFieldInput[]
disabled?: boolean
}>()
</script>

View File

@@ -0,0 +1,311 @@
<template>
<div class="space-y-4">
<!-- Toolbar + counter row -->
<div
v-if="$slots.toolbar || showCounter || showPerPage"
class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"
>
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<slot name="toolbar" />
</div>
<div class="flex items-center gap-3">
<div v-if="showPerPage && pagination?.perPageOptions?.length" class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="dt-per-page"
>
Par page
</label>
<select
id="dt-per-page"
:value="pagination.perPage"
class="select select-bordered select-sm"
@change="emit('update:perPage', Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="opt in pagination.perPageOptions" :key="opt" :value="opt">
{{ opt }}
</option>
</select>
</div>
<p v-if="showCounter && pagination" class="text-xs text-base-content/50 whitespace-nowrap">
{{ pagination.pageItems }} / {{ pagination.totalItems }}
résultat{{ pagination.totalItems > 1 ? 's' : '' }}
</p>
</div>
</div>
<!-- Loading state (full spinner only when no filterable columns to keep visible) -->
<div v-if="loading && !hasFilterableColumns" class="flex justify-center py-8">
<slot name="loading">
<span class="loading loading-spinner" aria-hidden="true" />
</slot>
</div>
<!-- Empty state (no data at all, no filterable columns to keep visible) -->
<template v-else-if="isEmpty && !hasFilterableColumns">
<slot name="empty">
<p class="text-sm text-base-content/70 py-8 text-center">
{{ emptyMessage }}
</p>
</slot>
</template>
<!-- No results without filterable columns -->
<template v-else-if="rows.length === 0 && !hasFilterableColumns">
<slot name="no-results">
<p class="text-sm text-base-content/70 py-8 text-center">
{{ noResultsMessage }}
</p>
</slot>
</template>
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
<template v-else>
<div class="overflow-x-auto overflow-y-clip relative rounded-lg border border-base-300/40">
<!-- Loading overlay (keeps table & filter inputs visible) -->
<div
v-if="loading && hasFilterableColumns"
class="absolute inset-0 bg-base-100/60 backdrop-blur-[1px] z-10 flex items-center justify-center"
>
<span class="loading loading-spinner text-primary" aria-hidden="true" />
</div>
<table :class="['table table-sm md:table-md', tableClass, { 'table-fixed': fixedLayout }]">
<thead>
<!-- Header labels + sort -->
<tr>
<th
v-for="col in columns"
:key="col.key"
:class="[
col.width,
col.class,
col.headerClass,
alignClass(col),
{ 'hidden sm:table-cell': col.hiddenMobile },
]"
:style="col.minWidth ? { minWidth: col.minWidth } : undefined"
>
<slot :name="`header-${col.key}`" :column="col">
<span
:class="[
'inline-flex items-center gap-1',
col.sortable ? 'cursor-pointer select-none hover:text-base-content' : '',
]"
@click="col.sortable && handleHeaderSort(col)"
>
{{ col.label }}
<template v-if="col.sortable">
<IconLucideChevronUp
v-if="isSortedAsc(col)"
class="h-3.5 w-3.5"
aria-label="Trié croissant"
/>
<IconLucideChevronDown
v-else-if="isSortedDesc(col)"
class="h-3.5 w-3.5"
aria-label="Trié décroissant"
/>
<IconLucideChevronsUpDown
v-else
class="h-3.5 w-3.5 opacity-30"
aria-label="Triable"
/>
</template>
</span>
</slot>
</th>
<th v-if="expandable" class="w-12" />
</tr>
<!-- Filter inputs row -->
<tr v-if="hasFilterableColumns">
<th
v-for="col in columns"
:key="`filter-${col.key}`"
class="p-1"
:class="{ 'hidden sm:table-cell': col.hiddenMobile }"
>
<input
v-if="col.filterable"
type="text"
class="input input-bordered input-xs w-full"
:placeholder="col.filterPlaceholder || 'Filtrer…'"
:value="columnFilters[col.key] ?? ''"
@input="handleFilterInput(col.key, ($event.target as HTMLInputElement).value)"
/>
</th>
<th v-if="expandable" />
</tr>
</thead>
<tbody>
<!-- No results message (inside table to keep headers visible) -->
<tr v-if="rows.length === 0">
<td :colspan="expandable ? columns.length + 1 : columns.length" class="text-center py-8">
<p class="text-sm text-base-content/70">
{{ isEmpty ? emptyMessage : noResultsMessage }}
</p>
</td>
</tr>
<template v-for="(row, idx) in rows" :key="getRowKey(row)">
<tr>
<td
v-for="col in columns"
:key="col.key"
:class="[
col.class,
alignClass(col),
{ 'hidden sm:table-cell': col.hiddenMobile },
]"
>
<slot :name="`cell-${col.key}`" :row="row" :column="col" :index="idx">
{{ row[col.key] ?? '—' }}
</slot>
</td>
<td v-if="expandable" class="text-center">
<button
v-if="!canExpand || canExpand(row)"
type="button"
class="btn btn-ghost btn-xs"
@click="emit('toggle-expand', getRowKey(row))"
>
{{ isExpanded(row) ? 'Masquer' : 'Voir' }}
</button>
<span v-else class="text-xs text-base-content/50"></span>
</td>
</tr>
<!-- Expanded row -->
<tr v-if="expandable && isExpanded(row)">
<td :colspan="columns.length + 1" class="bg-base-200/30 p-4 border-t border-base-200/80">
<slot name="row-expanded" :row="row" :index="idx" />
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination -->
<Pagination
v-if="pagination && pagination.totalPages > 1"
:current-page="pagination.currentPage"
:total-pages="pagination.totalPages"
@update:current-page="emit('update:currentPage', $event)"
/>
</template>
<slot name="footer" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { DataTableColumn, DataTableSort, DataTablePagination, DataTableColumnFilters } from '~/shared/types/dataTable'
import Pagination from '~/components/common/Pagination.vue'
import IconLucideChevronUp from '~icons/lucide/chevron-up'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
const props = withDefaults(defineProps<{
columns: DataTableColumn[]
rows: any[]
rowKey?: string
loading?: boolean
sort?: DataTableSort | null
pagination?: DataTablePagination | null
columnFilters?: DataTableColumnFilters
emptyMessage?: string
noResultsMessage?: string
expandable?: boolean
expandedKeys?: Set<string>
canExpand?: (row: any) => boolean
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,
sort: null,
pagination: null,
columnFilters: () => ({}),
emptyMessage: 'Aucune donnée disponible.',
noResultsMessage: 'Aucun résultat ne correspond à vos critères.',
expandable: false,
expandedKeys: () => new Set<string>(),
canExpand: undefined,
tableClass: '',
showCounter: true,
showPerPage: false,
})
const emit = defineEmits<{
(e: 'sort', sort: DataTableSort): void
(e: 'update:currentPage', page: number): void
(e: 'update:perPage', perPage: number): void
(e: 'update:columnFilters', filters: DataTableColumnFilters): void
(e: 'toggle-expand', key: string): void
}>()
const hasFilterableColumns = computed(() =>
props.columns.some(col => col.filterable),
)
const isEmpty = computed(() => {
if (props.pagination) {
return props.pagination.totalItems === 0
}
return props.rows.length === 0
})
const getRowKey = (row: any): string => {
return String(row[props.rowKey] ?? '')
}
const isExpanded = (row: any): boolean => {
return props.expandedKeys?.has(getRowKey(row)) ?? false
}
const sortKeyForColumn = (col: DataTableColumn): string => {
return col.sortKey ?? col.key
}
const isSortedAsc = (col: DataTableColumn): boolean => {
return props.sort?.field === sortKeyForColumn(col) && props.sort?.direction === 'asc'
}
const isSortedDesc = (col: DataTableColumn): boolean => {
return props.sort?.field === sortKeyForColumn(col) && props.sort?.direction === 'desc'
}
const handleHeaderSort = (col: DataTableColumn) => {
const key = sortKeyForColumn(col)
const currentDirection = props.sort?.field === key ? props.sort.direction : null
emit('sort', {
field: key,
direction: currentDirection === 'asc' ? 'desc' : 'asc',
})
}
let filterDebounceTimer: ReturnType<typeof setTimeout> | null = null
const handleFilterInput = (key: string, value: string) => {
if (filterDebounceTimer) clearTimeout(filterDebounceTimer)
filterDebounceTimer = setTimeout(() => {
const updated = { ...props.columnFilters, [key]: value }
// Remove empty filter keys
for (const k of Object.keys(updated)) {
if (!updated[k]) delete updated[k]
}
emit('update:columnFilters', updated)
}, 300)
}
const alignClass = (col: DataTableColumn): string => {
if (col.align === 'center') return 'text-center'
if (col.align === 'right') return 'text-right'
return ''
}
</script>

View File

@@ -0,0 +1,118 @@
<template>
<div v-if="documents.length" class="space-y-2">
<div
v-for="document in documents"
:key="document.id || document.path || document.name"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium flex items-center gap-2">
{{ document.name }}
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
</div>
<div class="text-xs text-base-content/70">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
title="Modifier"
@click="$emit('edit', document)"
>
Modifier
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="$emit('preview', document)"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(document)"
>
Télécharger
</button>
<button
v-if="canDelete"
type="button"
class="btn btn-error btn-xs"
:disabled="deleteDisabled"
@click="$emit('delete', document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-base-content/70">
{{ emptyText }}
</p>
</template>
<script setup lang="ts">
import { getDocumentTypeLabel } from '~/shared/documentTypes'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import {
documentIcon,
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import type { Document } from '~/composables/useDocuments'
withDefaults(defineProps<{
documents: Document[]
canDelete?: boolean
canEdit?: boolean
deleteDisabled?: boolean
emptyText?: string
}>(), {
canDelete: false,
canEdit: false,
deleteDisabled: false,
emptyText: 'Aucun document.',
})
defineEmits<{
(e: 'preview', document: Document): void
(e: 'delete', documentId: string): void
(e: 'edit', document: Document): void
}>()
</script>

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,97 @@
<template>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Historique</h2>
<p class="text-xs text-base-content/70">
Qui a changé quoi, et quand.
</p>
</div>
<span v-if="entries.length" class="badge badge-outline">
{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de l'historique…
</div>
<div v-else-if="error" class="alert alert-warning">
<span>{{ error }}</span>
</div>
<p v-else-if="entries.length === 0" class="text-xs text-base-content/70">
Aucun changement enregistré pour le moment.
</p>
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in entries"
:key="entry.id"
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
<span class="font-medium text-base-content">
{{ historyActionLabel(entry.action) }}
</span>
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
</div>
<p class="mt-1 text-xs text-base-content/60">
Par {{ entry.actor?.label || 'Inconnu' }}
</p>
<ul
v-if="diffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in diffEntries(entry)"
:key="`${entry.id}-${diffEntry.field}`"
class="flex flex-col gap-0.5"
>
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
<span class="text-base-content/60">
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
</span>
</li>
</ul>
<p
v-else-if="entry.snapshot?.name"
class="mt-2 text-xs text-base-content/70"
>
{{ entry.snapshot.name }}
</p>
</li>
</ul>
</section>
</template>
<script setup lang="ts">
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries,
type HistoryDiffEntry,
} from '~/shared/utils/historyDisplayUtils'
interface HistoryEntry {
id: string
action: string
createdAt: string
actor?: { label?: string } | null
diff?: Record<string, { from?: unknown; to?: unknown }> | null
snapshot?: { name?: string } | null
}
const props = defineProps<{
entries: HistoryEntry[]
loading?: boolean
error?: string | null
fieldLabels: Record<string, string>
}>()
const diffEntries = (entry: HistoryEntry): HistoryDiffEntry[] =>
historyDiffEntries(entry, props.fieldLabels)
</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,170 @@
<template>
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-base-content">Versions</h2>
<p class="text-xs text-base-content/70">
Historique des versions avec possibilite de restauration.
</p>
</div>
<span v-if="versions.length" class="badge badge-outline">
{{ versions.length }} version{{ versions.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement des versions...
</div>
<div v-else-if="error" class="alert alert-warning">
<span>{{ error }}</span>
</div>
<p v-else-if="versions.length === 0" class="text-xs text-base-content/70">
Aucune version enregistree.
</p>
<ul v-else class="max-h-96 space-y-2 overflow-y-auto pr-1">
<li
v-for="entry in versions"
:key="entry.version"
class="flex items-center justify-between rounded-md border border-base-200 bg-base-100 px-3 py-2"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-mono text-sm font-semibold">v{{ entry.version }}</span>
<span
v-if="entry.version === currentVersion"
class="badge badge-primary badge-sm"
>
actuelle
</span>
<span
v-if="entry.action === 'restore'"
class="badge badge-warning badge-sm"
>
restauration
</span>
</div>
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
<span>{{ actionLabel(entry.action) }}</span>
<span>&middot;</span>
<span>{{ formatDate(entry.createdAt) }}</span>
<span v-if="entry.actor">&middot; {{ entry.actor.label }}</span>
</div>
<div v-if="entry.diff && Object.keys(entry.diff).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="(change, field) in entry.diff"
:key="field"
class="badge badge-ghost badge-xs"
>
{{ formatDiffEntry(String(field), change) }}
</span>
</div>
</div>
<button
v-if="canRestore && entry.version !== currentVersion"
class="btn btn-ghost btn-xs"
:disabled="restoring"
@click="handleRestore(entry.version)"
>
Restaurer
</button>
</li>
</ul>
<VersionRestoreModal
:visible="modalVisible"
:preview="previewData"
:restoring="restoring"
:field-labels="fieldLabels"
:entity-type="entityType"
@close="modalVisible = false"
@confirm="confirmRestore"
/>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, toRef } from 'vue'
import { useEntityVersions, type RestorePreview } from '~/composables/useEntityVersions'
import { usePermissions } from '~/composables/usePermissions'
import { formatHistoryDate, historyActionLabel } from '~/shared/utils/historyDisplayUtils'
import VersionRestoreModal from './VersionRestoreModal.vue'
const props = defineProps<{
entityType: 'machine' | 'composant' | 'piece' | 'product'
entityId: string
fieldLabels: Record<string, string>
/** Increment this value to force a refresh of the versions list */
refreshKey?: number
}>()
const emit = defineEmits<{
restored: []
}>()
const { canEdit } = usePermissions()
const canRestore = computed(() => canEdit.value)
const { versions, loading, error, fetchVersions, fetchPreview, restore } = useEntityVersions({
entityType: props.entityType,
entityId: props.entityId,
})
const currentVersion = computed(() => {
if (versions.value.length === 0) return null
return versions.value[0]?.version ?? null
})
const modalVisible = ref(false)
const previewData = ref<RestorePreview | null>(null)
const restoring = ref(false)
const targetVersion = ref<number | null>(null)
const actionLabel = (action: string) => historyActionLabel(action)
const formatDate = (date: string) => formatHistoryDate(date)
const formatDiffEntry = (field: string, change: { from: unknown; to: unknown }): string => {
const label = props.fieldLabels[field] || field
// Link changes (addedComponent, removedPiece, etc.) have {id, name} as value
const val = change.to ?? change.from
if (val && typeof val === 'object' && 'name' in (val as Record<string, unknown>)) {
return `${label}: ${(val as Record<string, unknown>).name}`
}
return label
}
const handleRestore = async (version: number) => {
targetVersion.value = version
previewData.value = null
modalVisible.value = true
previewData.value = await fetchPreview(version)
}
const confirmRestore = async () => {
if (!targetVersion.value) return
restoring.value = true
const result = await restore(targetVersion.value)
restoring.value = false
if (result?.success) {
modalVisible.value = false
await fetchVersions()
emit('restored')
}
else {
error.value = 'La restauration a echoue.'
modalVisible.value = false
}
}
onMounted(() => {
fetchVersions()
})
// Auto-refresh when parent signals a data change
watch(toRef(props, 'refreshKey'), () => {
fetchVersions()
})
</script>

View File

@@ -0,0 +1,128 @@
<template>
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2">
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage <= 1"
@click="goToPage(1)"
>
<IconLucideChevronFirst class="w-4 h-4" />
</button>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
>
<IconLucideChevronLeft class="w-4 h-4" />
</button>
<template v-for="page in visiblePages" :key="page">
<span v-if="page === 'ellipsis-start' || page === 'ellipsis-end'" class="px-2">...</span>
<button
v-else
type="button"
class="btn btn-sm"
:class="page === currentPage ? 'btn-primary' : 'btn-ghost'"
@click="goToPage(page)"
>
{{ page }}
</button>
</template>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)"
>
<IconLucideChevronRight class="w-4 h-4" />
</button>
<button
type="button"
class="btn btn-sm btn-ghost"
:disabled="currentPage >= totalPages"
@click="goToPage(totalPages)"
>
<IconLucideChevronLast class="w-4 h-4" />
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconLucideChevronFirst from '~icons/lucide/chevrons-left'
import IconLucideChevronLeft from '~icons/lucide/chevron-left'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideChevronLast from '~icons/lucide/chevrons-right'
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalPages: {
type: Number,
required: true
},
maxVisiblePages: {
type: Number,
default: 5
}
})
const emit = defineEmits(['update:currentPage'])
const visiblePages = computed(() => {
const pages = []
const total = props.totalPages
const current = props.currentPage
const maxVisible = props.maxVisiblePages
if (total <= maxVisible + 2) {
for (let i = 1; i <= total; i++) {
pages.push(i)
}
return pages
}
// Always show first page
pages.push(1)
const half = Math.floor(maxVisible / 2)
let start = Math.max(2, current - half)
let end = Math.min(total - 1, current + half)
// Adjust if near start
if (current <= half + 1) {
end = maxVisible
}
// Adjust if near end
if (current >= total - half) {
start = total - maxVisible + 1
}
if (start > 2) {
pages.push('ellipsis-start')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < total - 1) {
pages.push('ellipsis-end')
}
// Always show last page
pages.push(total)
return pages
})
const goToPage = (page) => {
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
emit('update:currentPage', page)
}
}
</script>

View File

@@ -0,0 +1,406 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<h3 class="card-title text-lg">{{ labels.headerTitle }}</h3>
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
{{ labels.addButton }}
</button>
</div>
<p class="text-sm text-base-content/50">
{{ labels.description }}
</p>
<div v-if="requirements.length === 0" class="text-sm text-base-content/50 bg-base-200/60 rounded-md p-4">
{{ labels.emptyState }}
</div>
<div
v-for="(requirement, index) in requirements"
:key="requirement.id || index"
class="relative border border-base-200 rounded-lg p-4 pl-12 space-y-3 transition-colors"
:class="requirementReorderClass(index)"
@dragenter="onRequirementDragEnter(index)"
@dragover="onRequirementDragOver"
@drop="onRequirementDrop(index)"
>
<button
type="button"
class="absolute left-3 top-4 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
draggable="true"
title="Réorganiser"
@dragstart="onRequirementDragStart(index, $event)"
@dragend="onRequirementDragEnd"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex items-start justify-between gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.typeSelectLabel }}</span>
<span class="label-text-alt text-error">*</span>
</label>
<SearchSelect
:model-value="normalizeTypeModel(requirement[typeField])"
:options="typeOptions"
:loading="typeLoading"
size="sm"
:placeholder="labels.typePlaceholder"
:empty-text="typeOptions.length ? 'Aucun résultat' : 'Aucune option disponible'"
:option-label="optionLabel"
:option-description="optionDescription"
@update:modelValue="(value) => updateRequirement(index, { [typeField]: normalizeTypeValue(value) })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.labelFieldLabel }}</span>
<span v-if="labels.labelFieldHelper" class="label-text-alt text-xs">{{ labels.labelFieldHelper }}</span>
</label>
<input
:value="requirement.label ?? ''"
type="text"
class="input input-bordered input-sm"
:placeholder="labels.labelPlaceholder"
@input="handleLabelInput(index, $event)"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.minLabel }}</span>
</label>
<input
:value="requirement.minCount ?? minFallback"
type="number"
min="0"
class="input input-bordered input-sm"
@input="handleMinInput(index, $event)"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.maxLabel }}</span>
<span v-if="labels.maxHelper" class="label-text-alt text-xs">{{ labels.maxHelper }}</span>
</label>
<input
:value="requirement.maxCount ?? ''"
type="number"
min="0"
class="input input-bordered input-sm"
@input="handleMaxInput(index, $event)"
/>
</div>
</div>
<button
type="button"
class="btn btn-square btn-error btn-sm"
@click="removeRequirement(index)"
>
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="(requirement.required ?? requiredFallback) === true"
@change="handleRequiredChange(index, $event)"
/>
{{ labels.requiredLabel }}
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="(requirement.allowNewModels ?? allowNewModelsFallback) === true"
@change="handleAllowNewModelsChange(index, $event)"
/>
{{ labels.allowNewModelsLabel }}
</label>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { PropType } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import SearchSelect from '~/components/common/SearchSelect.vue'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
type Option = {
id: string | number
name: string
description?: string | null
}
type Requirement = Record<string, unknown> & {
id?: string | number
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
orderIndex?: number | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper?: string
labelPlaceholder?: string
minLabel: string
maxLabel: string
maxHelper?: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array as PropType<Requirement[]>,
default: () => [],
},
typeOptions: {
type: Array as PropType<Option[]>,
default: () => [],
},
typeField: {
type: String,
required: true,
},
labels: {
type: Object as PropType<Labels>,
required: true,
},
defaultRequirement: {
type: Function as PropType<() => Requirement>,
required: true,
},
requiredFallback: {
type: Boolean,
default: false,
},
allowNewModelsFallback: {
type: Boolean,
default: true,
},
minFallback: {
type: Number,
default: 0,
},
typeLoading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const requirements = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const optionLabel = (option: Option) => {
if (!option) {
return ''
}
return option.name || ''
}
const optionDescription = (option: Option) => {
if (!option) {
return ''
}
if (typeof option.description === 'string' && option.description.trim()) {
return option.description.trim()
}
return ''
}
const applyOrderIndex = (list: Requirement[]): Requirement[] =>
list.map((item, index) => ({
...item,
orderIndex: index,
}))
const addRequirement = () => {
requirements.value = applyOrderIndex([
...requirements.value,
props.defaultRequirement(),
])
}
const removeRequirement = (index: number) => {
requirements.value = applyOrderIndex(
requirements.value.filter((_, i) => i !== index),
)
}
const updateRequirement = (index: number, patch: Partial<Requirement>) => {
requirements.value = applyOrderIndex(
requirements.value.map((item, i) =>
i === index ? { ...item, ...patch } : item,
),
)
}
const parseNumber = (value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const parseOptionalNumber = (value: string) => {
if (value === '' || value === null || value === undefined) {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
// Type-safe event handlers
const getInputValue = (event: Event): string => {
const target = event.target as HTMLInputElement | null
return target?.value ?? ''
}
const getCheckboxValue = (event: Event): boolean => {
const target = event.target as HTMLInputElement | null
return target?.checked ?? false
}
const handleLabelInput = (index: number, event: Event) => {
updateRequirement(index, { label: getInputValue(event) })
}
const handleMinInput = (index: number, event: Event) => {
updateRequirement(index, { minCount: parseNumber(getInputValue(event)) })
}
const handleMaxInput = (index: number, event: Event) => {
updateRequirement(index, { maxCount: parseOptionalNumber(getInputValue(event)) })
}
const handleRequiredChange = (index: number, event: Event) => {
updateRequirement(index, { required: getCheckboxValue(event) })
}
const handleAllowNewModelsChange = (index: number, event: Event) => {
updateRequirement(index, { allowNewModels: getCheckboxValue(event) })
}
const draggingRequirementIndex = ref<number | null>(null)
const requirementDropTargetIndex = ref<number | null>(null)
const resetRequirementDragState = () => {
draggingRequirementIndex.value = null
requirementDropTargetIndex.value = null
}
const reorderRequirements = (from: number, to: number) => {
const list = requirements.value
if (!Array.isArray(list)) {
resetRequirementDragState()
return
}
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetRequirementDragState()
return
}
const updated = list.slice() as Requirement[]
const [moved] = updated.splice(from, 1)
if (!moved) {
resetRequirementDragState()
return
}
updated.splice(to, 0, moved)
requirements.value = applyOrderIndex(updated)
resetRequirementDragState()
}
const onRequirementDragStart = (index: number, event: DragEvent) => {
draggingRequirementIndex.value = index
requirementDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onRequirementDragEnter = (index: number) => {
if (draggingRequirementIndex.value === null) {
return
}
requirementDropTargetIndex.value = index
}
const onRequirementDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onRequirementDrop = (index: number) => {
if (draggingRequirementIndex.value === null) {
resetRequirementDragState()
return
}
reorderRequirements(draggingRequirementIndex.value, index)
}
const onRequirementDragEnd = () => {
resetRequirementDragState()
}
const requirementReorderClass = (index: number) => {
if (draggingRequirementIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingRequirementIndex.value !== null &&
requirementDropTargetIndex.value === index &&
draggingRequirementIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
const normalizeTypeModel = (value: unknown) => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string' || typeof value === 'number') {
return value
}
return ''
}
const normalizeTypeValue = (value: string | number | null | undefined) => {
if (value === '' || value === null || value === undefined) {
return null
}
return value
}
</script>
<!--
Éditeur générique de groupes de contraintes (pièces/composants) pour les types de machine.
Paramétrer les libellés et la structure via les props pour réutiliser ce bloc.
-->

View File

@@ -0,0 +1,362 @@
<template>
<div class="space-y-1 search-select">
<label v-if="$slots.label" class="label">
<span class="label-text">
<slot name="label" />
</span>
</label>
<div class="relative">
<input
ref="inputRef"
v-model="searchTerm"
type="text"
:placeholder="placeholder"
:class="inputClasses"
@focus="handleFocus"
@keydown.down.prevent="highlightNext"
@keydown.up.prevent="highlightPrevious"
@keydown.enter.prevent="selectHighlighted"
@input="handleInput"
>
<button
v-if="clearable && modelValue"
type="button"
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
aria-label="Effacer la sélection"
@click.stop="clearSelection"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
<button
type="button"
:class="toggleButtonClasses"
@click="toggleDropdown"
aria-label="Afficher les options"
>
<IconLucideChevronsUpDown class="w-4 h-4" aria-hidden="true" />
</button>
<transition name="fade">
<div
v-if="openDropdown"
class="absolute z-30 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
>
<div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-base-content/50">
<span class="loading loading-spinner loading-xs" />
Recherche en cours
</div>
<div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-base-content/50">
{{ emptyText }}
</div>
<ul v-else class="flex flex-col">
<li
v-for="(option, index) in displayedOptions"
:key="resolveValue(option) ?? index"
>
<button
type="button"
class="flex w-full flex-col items-start gap-1 px-3 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
:class="{
'bg-base-200': isOptionSelected(option),
'bg-base-300/60': highlightedIndex === index
}"
@mouseenter="highlightedIndex = index"
@mouseleave="highlightedIndex = -1"
@click="selectOption(option)"
>
<span class="font-medium text-sm">
<slot name="option-label" :option="option">
{{ resolveLabel(option) }}
</slot>
</span>
<span v-if="$slots['option-description'] || resolveDescription(option)" class="text-xs text-base-content/50">
<slot name="option-description" :option="option">
{{ resolveDescription(option) }}
</slot>
</span>
</button>
</li>
</ul>
</div>
</transition>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
},
options: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: 'Rechercher…'
},
emptyText: {
type: String,
default: 'Aucun résultat'
},
loading: {
type: Boolean,
default: false
},
optionValue: {
type: [String, Function],
default: 'id'
},
optionLabel: {
type: [String, Function],
default: 'name'
},
optionDescription: {
type: [String, Function],
default: null
},
clearable: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'md',
validator: (value) => ['xs', 'sm', 'md', 'lg'].includes(value)
},
maxVisible: {
type: Number,
default: 50
},
serverSearch: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'search'])
const searchTerm = ref('')
const openDropdown = ref(false)
const highlightedIndex = ref(-1)
const inputRef = ref(null)
const baseOptions = computed(() => Array.isArray(props.options) ? props.options : [])
const selectedOption = computed(() => {
return baseOptions.value.find(option => isEqualValue(resolveValue(option), props.modelValue)) || null
})
const displayedOptions = computed(() => {
const items = baseOptions.value.slice()
const filtered = (!props.serverSearch && searchTerm.value.trim())
? items.filter((option) => {
const term = searchTerm.value.trim().toLowerCase()
const label = resolveLabel(option).toLowerCase()
const description = resolveDescription(option)?.toLowerCase() || ''
return label.includes(term) || description.includes(term)
})
: items
if (props.maxVisible && filtered.length > props.maxVisible) {
return filtered.slice(0, props.maxVisible)
}
return filtered
})
const inputClasses = computed(() => {
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
const base = ['input', 'input-bordered', 'w-full', pr]
if (props.size === 'xs') base.push('input-xs')
if (props.size === 'sm') base.push('input-sm')
if (props.size === 'lg') base.push('input-lg')
return base.join(' ')
})
const toggleButtonClasses = computed(() => {
const base = ['absolute', 'top-1/2', '-translate-y-1/2', 'right-2', 'btn', 'btn-ghost']
if (props.size === 'xs' || props.size === 'sm') {
base.push('btn-xs')
} else {
base.push('btn-sm')
}
return base.join(' ')
})
watch(
() => props.modelValue,
() => {
if (!openDropdown.value) {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
}
},
{ immediate: true }
)
watch(
baseOptions,
(_newOptions) => {
if (!openDropdown.value && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
},
{ deep: true }
)
watch(openDropdown, (isOpen) => {
if (isOpen) {
highlightedIndex.value = -1
}
})
function resolveValue (option) {
if (!option) {
return null
}
if (typeof props.optionValue === 'function') {
return props.optionValue(option)
}
return option[props.optionValue]
}
function resolveLabel (option) {
if (!option) {
return ''
}
if (typeof props.optionLabel === 'function') {
return props.optionLabel(option) || ''
}
return option[props.optionLabel] || ''
}
function resolveDescription (option) {
if (!option || !props.optionDescription) {
return ''
}
if (typeof props.optionDescription === 'function') {
return props.optionDescription(option) || ''
}
return option[props.optionDescription] || ''
}
function isEqualValue (a, b) {
if (a === b) {
return true
}
return String(a ?? '') === String(b ?? '')
}
function isOptionSelected (option) {
return isEqualValue(resolveValue(option), props.modelValue)
}
function selectOption (option) {
emit('update:modelValue', resolveValue(option) ?? '')
searchTerm.value = resolveLabel(option)
openDropdown.value = false
}
function handleFocus () {
openDropdown.value = true
if (searchTerm.value === '' && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
}
function toggleDropdown () {
openDropdown.value = !openDropdown.value
if (openDropdown.value && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
if (openDropdown.value && inputRef.value) {
inputRef.value.focus()
}
}
function handleInput () {
if (!openDropdown.value) {
openDropdown.value = true
}
emit('search', searchTerm.value)
}
function clearSelection () {
emit('update:modelValue', '')
searchTerm.value = ''
openDropdown.value = false
}
function closeDropdown () {
openDropdown.value = false
if (searchTerm.value.trim() === '' && selectedOption.value) {
emit('update:modelValue', '')
} else if (selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
}
function highlightNext () {
if (!openDropdown.value || displayedOptions.value.length === 0) {
return
}
highlightedIndex.value = (highlightedIndex.value + 1) % displayedOptions.value.length
}
function highlightPrevious () {
if (!openDropdown.value || displayedOptions.value.length === 0) {
return
}
highlightedIndex.value =
highlightedIndex.value <= 0
? displayedOptions.value.length - 1
: highlightedIndex.value - 1
}
function selectHighlighted () {
if (!openDropdown.value) {
return
}
if (highlightedIndex.value >= 0 && highlightedIndex.value < displayedOptions.value.length) {
selectOption(displayedOptions.value[highlightedIndex.value])
}
}
const handleGlobalClick = (event) => {
if (!openDropdown.value) {
return
}
const target = event.target
if (target?.closest?.('.search-select')) {
return
}
closeDropdown()
}
onMounted(() => {
window.addEventListener('click', handleGlobalClick)
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleGlobalClick)
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.12s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<div 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">
{{ description }}
</p>
</div>
<span class="badge badge-outline">{{ previewBadge }}</span>
</div>
<details v-if="structure" class="collapse collapse-arrow bg-base-100">
<summary class="collapse-title text-sm font-medium">
Consulter le détail du squelette
</summary>
<div class="collapse-content text-sm text-base-content/80" :class="variant === 'component' ? 'space-y-4' : 'space-y-2'">
<!-- Custom fields: component variant (rich display) -->
<div v-if="variant === 'component' && customFields.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="space-y-2">
<li
v-for="field in customFields"
:key="field.customFieldId || field.id || field.name"
class="rounded bg-base-200/60 px-3 py-2"
>
<p class="font-medium text-sm text-base-content">
{{ field.name || field.key }}
</p>
<p class="text-xs text-base-content/70 mt-1">
Type : {{ field.type || 'text' }}<span v-if="field.required"> &bull; Obligatoire</span>
<span v-if="Array.isArray(field.options) && field.options.length">
&bull; Options : {{ field.options.join(', ') }}
</span>
<span v-if="field.defaultValue">
&bull; Défaut : {{ field.defaultValue }}
</span>
</p>
</li>
</ul>
</div>
<!-- Custom fields: piece variant (simple display) -->
<div v-if="variant === 'piece' && customFields.length" class="space-y-1">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="list-disc list-inside space-y-1">
<li v-for="field in customFields" :key="field.name">
<span class="font-medium">{{ field.name }}</span>
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
</li>
</ul>
</div>
<!-- Pieces: component variant only -->
<div v-if="variant === 'component' && pieces.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces imposées</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(piece, index) in pieces"
:key="piece.role || piece.typePieceId || piece.familyCode || index"
>
{{ resolvePieceLabelFn(piece) }}
</li>
</ul>
</div>
<!-- Products: component variant only -->
<div v-if="variant === 'component' && products.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(product, index) in products"
:key="product.role || product.typeProductId || product.familyCode || index"
>
{{ resolveProductLabelFn(product) }}
</li>
</ul>
</div>
<!-- Subcomponents: component variant only -->
<div v-if="variant === 'component' && subcomponents.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(subcomponent, index) in subcomponents"
:key="subcomponent.alias || subcomponent.typeComposantId || subcomponent.familyCode || index"
>
{{ resolveSubcomponentLabelFn(subcomponent) }}
</li>
</ul>
</div>
<!-- Empty state: component variant -->
<p
v-if="variant === 'component' && showEmptyState && !customFields.length && !pieces.length && !products.length && !subcomponents.length"
class="text-xs text-base-content/50"
>
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
</p>
<!-- Empty state: piece variant -->
<p v-if="variant === 'piece' && !customFields.length" class="text-xs text-base-content/70">
Ce squelette ne définit pas encore de champs personnalisés.
</p>
</div>
</details>
</div>
</template>
<script setup lang="ts">
import {
getStructureCustomFields,
getStructurePieces,
getStructureProducts,
getStructureSubcomponents,
} from '~/shared/utils/structureDisplayUtils'
const props = withDefaults(defineProps<{
structure: Record<string, any> | null
description?: string
previewBadge: string
variant: 'component' | 'piece'
showEmptyState?: boolean
resolvePieceLabel?: (piece: Record<string, any>) => string
resolveProductLabel?: (product: Record<string, any>) => string
resolveSubcomponentLabel?: (subcomponent: Record<string, any>) => string
}>(), {
description: '',
showEmptyState: false,
resolvePieceLabel: undefined,
resolveProductLabel: undefined,
resolveSubcomponentLabel: undefined,
})
const customFields = computed(() =>
getStructureCustomFields(props.structure),
)
const pieces = computed(() =>
props.variant === 'component' ? getStructurePieces(props.structure) : [],
)
const products = computed(() =>
props.variant === 'component' ? getStructureProducts(props.structure) : [],
)
const subcomponents = computed(() =>
props.variant === 'component' ? getStructureSubcomponents(props.structure) : [],
)
const fallbackLabel = (item: Record<string, any>) =>
item?.name || item?.label || item?.role || item?.alias || 'N/A'
const resolvePieceLabelFn = (piece: Record<string, any>) =>
props.resolvePieceLabel ? props.resolvePieceLabel(piece) : fallbackLabel(piece)
const resolveProductLabelFn = (product: Record<string, any>) =>
props.resolveProductLabel ? props.resolveProductLabel(product) : fallbackLabel(product)
const resolveSubcomponentLabelFn = (subcomponent: Record<string, any>) =>
props.resolveSubcomponentLabel ? props.resolveSubcomponentLabel(subcomponent) : fallbackLabel(subcomponent)
</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,198 @@
<template>
<dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
<div class="modal-box max-w-lg">
<h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>
<div v-if="!preview" class="flex justify-center py-8">
<span class="loading loading-spinner loading-md" />
</div>
<template v-else>
<div class="mt-4 space-y-4">
<!-- Restore mode explanation -->
<div
class="alert text-sm"
:class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
>
<div class="flex flex-col gap-1">
<!-- FULL MODE -->
<template v-if="preview.restoreMode === 'full'">
<span class="font-semibold">Restauration complete</span>
<!-- Machine: always full, no category -->
<template v-if="entityType === 'machine'">
<span>Tous les elements de la machine seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, prix</li>
<li>Site</li>
<li>Fournisseurs</li>
<li>Composants, pieces et produits lies</li>
<li>Champs personnalises</li>
</ul>
</template>
<!-- Composant -->
<template v-else-if="entityType === 'composant'">
<span>La categorie est identique. Tous les elements du composant seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
<li>Structure : pieces, sous-composants et produits lies</li>
<li>Champs personnalises</li>
</ul>
</template>
<!-- Piece -->
<template v-else-if="entityType === 'piece'">
<span>La categorie est identique. Tous les elements de la piece seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
<li>Produits lies</li>
<li>Champs personnalises</li>
</ul>
</template>
<!-- Product -->
<template v-else-if="entityType === 'product'">
<span>La categorie est identique. Tous les elements du produit seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, prix fournisseur</li>
<li>Fournisseurs</li>
<li>Champs personnalises</li>
</ul>
</template>
</template>
<!-- PARTIAL MODE (never for machines) -->
<template v-else>
<span class="font-semibold">Restauration partielle</span>
<!-- Composant -->
<template v-if="entityType === 'composant'">
<span>La categorie du composant a change depuis cette version. Seuls les champs de base seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
</ul>
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
<ul class="ml-4 list-disc text-xs opacity-70">
<li>Structure actuelle (pieces, sous-composants, produits lies)</li>
<li>Champs personnalises actuels</li>
</ul>
</template>
<!-- Piece -->
<template v-else-if="entityType === 'piece'">
<span>La categorie de la piece a change depuis cette version. Seuls les champs de base seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
</ul>
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
<ul class="ml-4 list-disc text-xs opacity-70">
<li>Produits lies actuels</li>
<li>Champs personnalises actuels</li>
</ul>
</template>
<!-- Product -->
<template v-else-if="entityType === 'product'">
<span>La categorie du produit a change depuis cette version. Seuls les champs de base seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, prix fournisseur</li>
<li>Fournisseurs</li>
</ul>
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
<ul class="ml-4 list-disc text-xs opacity-70">
<li>Champs personnalises actuels</li>
</ul>
</template>
</template>
</div>
</div>
<!-- Diff -->
<div v-if="Object.keys(preview.diff).length" class="space-y-2">
<h4 class="text-sm font-semibold">Changements qui seront appliques</h4>
<ul class="space-y-1 text-sm">
<li
v-for="(change, field) in preview.diff"
:key="field"
class="flex flex-col rounded-md border border-base-200 px-3 py-2"
>
<span class="font-medium text-base-content">{{ fieldLabels[field] || formatFieldLabel(String(field)) }}</span>
<span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
<span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
</li>
</ul>
</div>
<div v-else class="text-sm text-base-content/60">
Aucune difference detectee l'entite est deja dans l'etat de cette version.
</div>
<!-- Warnings -->
<div v-if="preview.warnings.length" class="space-y-1">
<h4 class="text-sm font-semibold text-warning">Avertissements</h4>
<ul class="space-y-1">
<li
v-for="(warning, i) in preview.warnings"
:key="i"
class="alert alert-warning py-2 text-xs"
>
{{ warning.message }}
</li>
</ul>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
Annuler
</button>
<button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
<span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
Confirmer la restauration
</button>
</div>
</template>
</div>
<form method="dialog" class="modal-backdrop" @click="$emit('close')">
<button type="button">close</button>
</form>
</dialog>
</template>
<script setup lang="ts">
import type { RestorePreview } from '~/composables/useEntityVersions'
defineProps<{
visible: boolean
preview: RestorePreview | null
restoring: boolean
fieldLabels: Record<string, string>
entityType: 'machine' | 'composant' | 'piece' | 'product'
}>()
defineEmits<{
close: []
confirm: []
}>()
const formatFieldLabel = (field: string): string => {
if (field.startsWith('customField:')) {
return `Champ perso : ${field.replace('customField:', '')}`
}
return field
}
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '—'
if (Array.isArray(value)) {
return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
}
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
</script>

View File

@@ -0,0 +1,112 @@
<template>
<div class="form-control">
<label v-if="label" class="label" :for="inputId">
<span class="label-text">{{ label }}</span>
<span v-if="required" class="label-text-alt text-error">*</span>
</label>
<input
:id="inputId"
:value="modelValue"
type="email"
class="input input-bordered"
:class="{ 'input-error': Boolean(errorMessage) }"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:name="name"
:autocomplete="autocomplete"
:pattern="EMAIL_INPUT_PATTERN"
:aria-invalid="Boolean(errorMessage)"
:aria-describedby="describedBy"
@input="onInput"
@blur="onBlur"
@focus="(event) => emit('focus', event)"
/>
<p v-if="help" :id="helpId" class="mt-2 text-xs text-gray-500">
{{ help }}
</p>
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
{{ errorMessage }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, useId } from 'vue'
import { normalizeEmail } from '~/utils/formatters/email'
import { EMAIL_INPUT_PATTERN, EMAIL_VALIDATION_ERROR, emailSchema } from '~/shared/validation/email'
type Emits = {
(event: 'update:modelValue', value: string): void
(event: 'blur', value: FocusEvent): void
(event: 'focus', value: FocusEvent): void
}
const emit = defineEmits<Emits>()
const props = withDefaults(
defineProps<{
modelValue: string
label?: string
required?: boolean
error?: string | null
help?: string | null
placeholder?: string
disabled?: boolean
name?: string
id?: string
autocomplete?: string
normalizeOnBlur?: boolean
validateOnBlur?: boolean
}>(),
{
modelValue: '',
label: 'Email',
required: false,
error: null,
help: null,
placeholder: 'ex: contact@example.com',
disabled: false,
name: undefined,
id: undefined,
autocomplete: 'email',
normalizeOnBlur: false,
validateOnBlur: false,
}
)
const fallbackId = useId()
const inputId = computed(() => props.id || fallbackId)
const helpId = computed(() => (props.help ? `${inputId.value}-help` : undefined))
const errorMessage = computed(() => props.error || null)
const errorId = computed(() => (errorMessage.value ? `${inputId.value}-error` : undefined))
const describedBy = computed(() => [helpId.value, errorId.value].filter(Boolean).join(' ') || undefined)
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
const onBlur = (event: FocusEvent) => {
const target = event.target as HTMLInputElement
if (props.normalizeOnBlur) {
const normalized = normalizeEmail(target.value)
if (normalized !== target.value) {
target.value = normalized
emit('update:modelValue', normalized)
}
}
if (props.validateOnBlur && !errorMessage.value) {
const validation = emailSchema.validate(target.value)
if (!validation.valid) {
target.setCustomValidity(EMAIL_VALIDATION_ERROR)
} else {
target.setCustomValidity('')
}
}
emit('blur', event)
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="form-control">
<label v-if="label" class="label" :for="inputId">
<span class="label-text">{{ label }}</span>
<span v-if="required" class="label-text-alt text-error">*</span>
</label>
<input
:id="inputId"
:value="modelValue"
type="tel"
class="input input-bordered"
:class="{ 'input-error': Boolean(errorMessage) }"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:name="name"
:autocomplete="autocomplete"
:pattern="PHONE_INPUT_PATTERN"
inputmode="tel"
:aria-invalid="Boolean(errorMessage)"
:aria-describedby="describedBy"
@input="onInput"
@blur="onBlur"
@focus="(event) => emit('focus', event)"
/>
<p v-if="help" :id="helpId" class="mt-2 text-xs text-base-content/50">
{{ help }}
</p>
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
{{ errorMessage }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, useId } from 'vue'
import { formatPhone } from '~/utils/formatters/phone'
import { PHONE_INPUT_PATTERN, PHONE_VALIDATION_ERROR, phoneSchema } from '~/shared/validation/phone'
type Emits = {
(event: 'update:modelValue', value: string): void
(event: 'blur', value: FocusEvent): void
(event: 'focus', value: FocusEvent): void
}
const emit = defineEmits<Emits>()
const props = withDefaults(
defineProps<{
modelValue: string
label?: string
required?: boolean
error?: string | null
help?: string | null
placeholder?: string
disabled?: boolean
name?: string
id?: string
autocomplete?: string
normalizeOnBlur?: boolean
validateOnBlur?: boolean
}>(),
{
modelValue: '',
label: 'Téléphone',
required: false,
error: null,
help: null,
placeholder: 'Ex: 06 00 00 00 00',
disabled: false,
name: undefined,
id: undefined,
autocomplete: 'tel',
normalizeOnBlur: false,
validateOnBlur: false,
}
)
const fallbackId = useId()
const inputId = computed(() => props.id || fallbackId)
const helpId = computed(() => (props.help ? `${inputId.value}-help` : undefined))
const errorMessage = computed(() => props.error || null)
const errorId = computed(() => (errorMessage.value ? `${inputId.value}-error` : undefined))
const describedBy = computed(() => [helpId.value, errorId.value].filter(Boolean).join(' ') || undefined)
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
const onBlur = (event: FocusEvent) => {
const target = event.target as HTMLInputElement
if (props.normalizeOnBlur) {
const formatted = formatPhone(target.value)
if (formatted !== target.value) {
target.value = formatted
emit('update:modelValue', formatted)
}
}
if (props.validateOnBlur && !errorMessage.value) {
const validation = phoneSchema.validate(target.value)
if (!validation.valid) {
target.setCustomValidity(PHONE_VALIDATION_ERROR)
} else {
target.setCustomValidity('')
}
}
emit('blur', event)
}
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div v-if="open" class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4">
Ajouter une nouvelle machine
</h3>
<form @submit.prevent="handleSubmit">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la machine</span>
</label>
<input
v-model="form.name"
type="text"
placeholder="Ex: Presse hydraulique #1"
class="input input-bordered"
:disabled="disabled"
required
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Site</span>
</label>
<select
v-model="form.siteId"
class="select select-bordered"
:disabled="disabled"
required
>
<option value="">
Sélectionner un site
</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="form.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
:disabled="disabled"
>
</div>
<div class="modal-action">
<button
type="button"
class="btn btn-outline"
@click="$emit('close')"
>
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="disabled">
Créer la machine
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
const props = defineProps<{
open: boolean
sites: Array<{ id: string, name: string }>
disabled: boolean
preselectedSiteId?: string
}>()
const emit = defineEmits<{
close: []
create: [data: { name: string, siteId: string, reference: string }]
}>()
const form = reactive({
name: '',
siteId: '',
reference: '',
})
function handleSubmit() {
emit('create', { ...form })
}
watch(() => props.open, (isOpen) => {
if (isOpen && props.preselectedSiteId) {
form.siteId = props.preselectedSiteId
}
if (!isOpen) {
form.name = ''
form.siteId = ''
form.reference = ''
}
})
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div v-if="open" class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Ajouter un nouveau site
</h3>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du site</span>
</label>
<input
v-model="form.name"
type="text"
placeholder="Ex: Usine de production"
class="input input-bordered"
:disabled="disabled"
required
>
</div>
<SiteContactFormFields :form="form" :disabled="disabled" />
<div class="modal-action">
<button
type="button"
class="btn btn-outline"
@click="$emit('close')"
>
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="disabled">
Créer le site
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
const props = defineProps<{
open: boolean
disabled: boolean
}>()
const emit = defineEmits<{
close: []
create: [data: { name: string, contactName: string, contactPhone: string, contactAddress: string, contactPostalCode: string, contactCity: string }]
}>()
const form = reactive({
name: '',
contactName: '',
contactPhone: '',
contactAddress: '',
contactPostalCode: '',
contactCity: '',
})
function handleSubmit() {
emit('create', { ...form })
}
watch(() => props.open, (isOpen) => {
if (!isOpen) {
form.name = ''
form.contactName = ''
form.contactPhone = ''
form.contactAddress = ''
form.contactPostalCode = ''
form.contactCity = ''
}
})
</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

@@ -0,0 +1,453 @@
<template>
<div class="navbar navbar-glass sticky top-0 z-50 px-4 lg:px-6">
<div class="navbar-start">
<!-- Mobile hamburger menu -->
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm lg:hidden">
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-3 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
>
<li class="pt-1 pb-2 lg:hidden">
<button
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
@click="toggleDarkMode"
>
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
{{ isDark ? 'Mode clair' : 'Mode sombre' }}
</button>
</li>
<li class="pt-1 pb-2 lg:hidden">
<button
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
@click="$emit('open-settings')"
>
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
Paramètres d'affichage
</button>
</li>
<!-- Mobile: simple links -->
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
:to="link.to"
class="rounded-lg px-3 py-2 transition-all flex items-center gap-2"
:class="linkClass(link)"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }}
</NuxtLink>
</li>
<!-- Mobile: dropdown groups -->
<li
v-for="group in visibleGroups"
:key="group.id + '-mobile'"
class="mt-1 border-t border-base-200 pt-2"
>
<button
type="button"
class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-all"
:class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-mobile'"
@click="toggleDropdown(group.id + '-mobile')"
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
>
<span class="flex items-center gap-2">
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
</span>
<IconLucideChevronRight
class="h-3.5 w-3.5 transition-transform duration-200"
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-mobile">
<ul
v-if="openDropdown === group.id + '-mobile'"
class="mt-1 space-y-0.5 rounded-lg bg-base-200/50 p-2 overflow-hidden"
>
<li v-for="child in group.children" :key="child.to">
<NuxtLink
:to="child.to"
class="rounded-md px-3 py-1.5 transition-colors block text-sm"
:class="childLinkClass(child)"
>
{{ child.label }}
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
{{ unresolvedCount }}
</span>
</NuxtLink>
</li>
</ul>
</Transition>
</li>
</ul>
</div>
<!-- Logo -->
<NuxtLink to="/" class="flex items-center gap-2.5 group">
<div class="w-9 h-9 rounded-lg overflow-hidden ring-1 ring-base-300/50 transition-all group-hover:ring-primary/30 group-hover:shadow-md">
<img
:src="logoSrc"
alt="Logo Malio"
class="h-full w-full object-contain"
/>
</div>
<span class="text-lg font-bold tracking-tight text-base-content hidden sm:inline" style="font-family: var(--font-heading)">
Inventory
</span>
</NuxtLink>
</div>
<!-- Desktop navbar -->
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal gap-0.5 px-1">
<!-- Desktop: simple links -->
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
:to="link.to"
class="transition-all px-3 py-2 rounded-lg flex items-center gap-1.5 text-sm font-medium"
:class="linkClass(link)"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
{{ link.label }}
</NuxtLink>
</li>
<!-- Desktop: dropdown groups -->
<li
v-for="group in visibleGroups"
:key="group.id + '-desktop'"
class="relative"
@mouseenter="setDropdown(group.id + '-desktop')"
@mouseleave="scheduleDropdownClose(group.id + '-desktop')"
@focusin="setDropdown(group.id + '-desktop')"
@focusout="scheduleDropdownClose(group.id + '-desktop')"
>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 transition-all text-sm font-medium"
:class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-desktop'"
@click="toggleDropdown(group.id + '-desktop')"
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
>
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
<IconLucideChevronDown
class="h-3.5 w-3.5 transition-transform duration-200"
:class="openDropdown === group.id + '-desktop' ? 'rotate-180' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-desktop">
<ul
v-if="openDropdown === group.id + '-desktop'"
class="absolute left-0 top-full mt-1.5 w-56 rounded-xl border border-base-300/50 bg-base-100 p-1.5 shadow-lg shadow-base-content/5 z-50"
>
<li v-for="child in group.children" :key="child.to">
<NuxtLink
:to="child.to"
class="block rounded-lg px-3 py-2 transition-all text-sm"
:class="childLinkClass(child)"
>
{{ child.label }}
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
{{ unresolvedCount }}
</span>
</NuxtLink>
</li>
</ul>
</Transition>
</li>
</ul>
</div>
<!-- Navbar end -->
<div class="navbar-end">
<div class="flex items-center gap-1.5">
<button
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
:title="isDark ? 'Mode clair' : 'Mode sombre'"
@click="toggleDarkMode"
>
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
</button>
<button
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
title="Paramètres d'affichage"
@click="$emit('open-settings')"
>
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
</button>
<ClientOnly>
<div v-if="activeProfile" class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="indicator cursor-pointer"
>
<span
v-if="unresolvedCount > 0"
class="indicator-item badge badge-warning badge-xs"
>
{{ unresolvedCount }}
</span>
<div
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
>
<span class="text-xs font-semibold">
{{ activeProfileInitials }}
</span>
</div>
</div>
<ul
tabindex="0"
class="menu dropdown-content mt-3 p-2 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
>
<li class="px-3 py-2">
<div class="flex flex-col gap-1 pointer-events-none">
<span class="text-xs text-base-content/50">Connecté en tant que</span>
<span class="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
</div>
</li>
<div class="divider my-0.5 px-2" />
<li v-if="isAdmin">
<NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
Administration
<IconLucideChevronRight class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink>
</li>
<li>
<NuxtLink to="/comments" class="rounded-lg justify-between text-sm">
Commentaires
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
{{ unresolvedCount }}
</span>
<IconLucideChevronRight v-else class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink>
</li>
<div class="divider my-0.5 px-2" />
<li>
<button
type="button"
class="rounded-lg text-error/80 hover:text-error hover:bg-error/5 justify-between text-sm"
@click="$emit('logout')"
>
Déconnexion
<IconLucideLogOut class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</li>
</ul>
</div>
</ClientOnly>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
import { useRoute } from '#imports'
import { useNavDropdown } from '~/composables/useNavDropdown'
import { usePermissions } from '~/composables/usePermissions'
import { useProfileSession } from '~/composables/useProfileSession'
import { useComments } from '~/composables/useComments'
import IconLucideMenu from '~icons/lucide/menu'
import IconLucideSettings from '~icons/lucide/settings'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
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 IconLucidePackage from '~icons/lucide/package'
import IconLucideSun from '~icons/lucide/sun'
import IconLucideMoon from '~icons/lucide/moon'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
defineEmits<{
(e: 'open-settings'): void
(e: 'logout'): void
}>()
interface NavLink {
to: string
label: string
icon?: Component
}
interface NavGroup {
id: string
label: string
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: 'catalogues',
label: 'Catalogues',
icon: IconLucidePackage,
activePaths: ['/catalogues', '/component', '/piece', '/product'],
children: [
{ to: '/catalogues/composants', label: 'Composants' },
{ to: '/catalogues/pieces', label: 'Pièces' },
{ to: '/catalogues/produits', label: 'Produits' },
],
},
{
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' },
],
},
]
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()
const unresolvedCount = ref(0)
let pollInterval: ReturnType<typeof setInterval> | null = null
const refreshUnresolvedCount = async () => {
if (!activeProfile.value) return
unresolvedCount.value = await fetchUnresolvedCount()
}
onMounted(() => {
initDarkMode()
refreshUnresolvedCount()
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
})
onBeforeUnmount(() => {
if (pollInterval) clearInterval(pollInterval)
})
const isActive = (path: string) => {
if (path === '/') {
return route.path === '/'
}
return route.path.startsWith(path)
}
const isGroupActive = (group: NavGroup) => {
return group.activePaths.some((path) => isActive(path))
}
const linkClass = (link: NavLink) => {
return isActive(link.to)
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
}
const groupClass = (group: NavGroup) => {
return isGroupActive(group)
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
}
const childLinkClass = (child: NavLink) => {
return isActive(child.to)
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
}
const roleLabel = computed(() => {
if (isAdmin.value) return 'Admin'
if (canEdit.value) return 'Gestionnaire'
return 'Lecteur'
})
const roleBadgeClass = computed(() => {
if (isAdmin.value) return 'badge-error'
if (canEdit.value) return 'badge-warning'
return 'badge-info'
})
const activeProfileLabel = computed(() => {
if (!activeProfile.value) {
return 'Profil inconnu'
}
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`
})
const activeProfileInitials = computed(() => {
if (!activeProfile.value) {
return '??'
}
const { firstName = '', lastName = '' } = activeProfile.value
return (
`${firstName.charAt(0) || ''}${lastName.charAt(0) || ''}`.toUpperCase() || '??'
)
})
</script>
<style scoped>
.nav-dropdown-desktop-enter-active,
.nav-dropdown-desktop-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.nav-dropdown-desktop-enter-from,
.nav-dropdown-desktop-leave-to {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
.nav-dropdown-desktop-enter-to,
.nav-dropdown-desktop-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
}
.nav-dropdown-mobile-enter-active,
.nav-dropdown-mobile-leave-active {
transition: max-height 0.2s ease, opacity 0.2s ease;
}
.nav-dropdown-mobile-enter-from,
.nav-dropdown-mobile-leave-to {
max-height: 0;
opacity: 0;
}
.nav-dropdown-mobile-enter-to,
.nav-dropdown-mobile-leave-from {
max-height: 12rem;
opacity: 1;
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<div v-if="open" class="modal modal-open">
<div class="modal-box max-w-xl w-full" style="overflow: visible">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"
@click="handleClose"
>
&times;
</button>
<h3 class="font-bold text-lg mb-6">
{{ title }}
</h3>
<!-- Step 1: Choose category -->
<div class="form-control mb-5" style="position: relative; z-index: 20">
<label class="label pb-1">
<span class="label-text font-medium">Catégorie</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="types"
:loading="loadingTypes"
:max-visible="8"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="(t: any) => t.name"
:option-description="(t: any) => t.code"
/>
</div>
<!-- Step 2: Choose entity (visible only after category selected) -->
<div v-if="selectedTypeName" class="form-control mb-5" style="position: relative; z-index: 10">
<label class="label pb-1">
<span class="label-text font-medium">{{ entityLabel }}</span>
</label>
<SearchSelect
v-model="selectedEntityId"
:options="entities"
:loading="loadingEntities"
:max-visible="8"
:placeholder="`Rechercher ${entityLabelLower}...`"
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
:option-label="entityOptionLabel"
:option-description="entityOptionDescription"
server-search
@search="handleEntitySearch"
/>
</div>
<div v-if="selectedTypeName && !selectedEntityId && !loadingEntities" class="bg-warning/10 border border-warning rounded-lg p-3 mb-4">
<p class="text-sm text-warning font-medium">
Aucun item sélectionné — la catégorie sera ajoutée avec le statut "À remplir".
</p>
</div>
<!-- Summary of selection -->
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
<p v-if="selectedEntitySummary.reference" class="text-xs text-base-content/60">
Réf : {{ selectedEntitySummary.reference }}
</p>
</div>
<div class="modal-action mt-4 pt-4 border-t border-base-200" style="position: relative; z-index: 0">
<button type="button" class="btn btn-ghost" @click="handleClose">
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!selectedTypeId"
@click="handleConfirm"
>
{{ selectedEntityId ? 'Ajouter' : 'Ajouter (catégorie seule)' }}
</button>
</div>
</div>
<div class="modal-backdrop" @click="handleClose" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
type EntityKind = 'component' | 'piece' | 'product'
const props = defineProps<{
open: boolean
entityKind: EntityKind
prefillTypeId?: string
}>()
const emit = defineEmits<{
close: []
confirm: [payload: { entityId?: string; modelTypeId: string; modelTypeName: string }]
}>()
const selectedTypeId = ref('')
const selectedEntityId = ref('')
const loadingEntities = ref(false)
const entities = ref<any[]>([])
const { componentTypes, loadingComponentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadingPieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadingProductTypes, loadProductTypes } = useProductTypes()
const { loadComposants } = useComposants()
const { loadPieces } = usePieces()
const { loadProducts } = useProducts()
const title = computed(() => {
const labels: Record<EntityKind, string> = {
component: 'Ajouter un composant',
piece: 'Ajouter une pièce',
product: 'Ajouter un produit',
}
return labels[props.entityKind]
})
const entityLabel = computed(() => {
const labels: Record<EntityKind, string> = {
component: 'Composant',
piece: 'Pièce',
product: 'Produit',
}
return labels[props.entityKind]
})
const entityLabelLower = computed(() => entityLabel.value.toLowerCase())
const types = computed(() => {
if (props.entityKind === 'component') return componentTypes.value
if (props.entityKind === 'piece') return pieceTypes.value
return productTypes.value
})
const loadingTypes = computed(() => {
if (props.entityKind === 'component') return loadingComponentTypes.value
if (props.entityKind === 'piece') return loadingPieceTypes.value
return loadingProductTypes.value
})
const selectedTypeName = computed(() => {
if (!selectedTypeId.value) return ''
const found = types.value.find((t: any) => t.id === selectedTypeId.value)
return found?.name || ''
})
const entityOptionLabel = (e: any) => {
const name = e.name || '(sans nom)'
return e.reference ? `${name}${e.reference}` : name
}
const entityOptionDescription = (e: any) => e.reference || ''
const selectedEntitySummary = computed(() => {
if (!selectedEntityId.value || !entities.value.length) return null
const found = entities.value.find((e: any) => e.id === selectedEntityId.value)
if (!found) return null
return { name: found.name || '(sans nom)', reference: found.reference || null }
})
// Load types when modal opens
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
if (props.entityKind === 'component') await loadComponentTypes()
else if (props.entityKind === 'piece') await loadPieceTypes()
else await loadProductTypes()
if (props.prefillTypeId) {
selectedTypeId.value = props.prefillTypeId
}
})
// Load entities when type changes
watch(selectedTypeId, async () => {
selectedEntityId.value = ''
entities.value = []
if (!selectedTypeName.value) return
loadingEntities.value = true
try {
if (props.entityKind === 'component') {
const result = await loadComposants({ typeName: selectedTypeName.value, itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else if (props.entityKind === 'piece') {
const result = await loadPieces({ typeName: selectedTypeName.value, itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else {
const result = await loadProducts({ typeName: selectedTypeName.value, itemsPerPage: 200 })
entities.value = result?.data?.items || []
}
} finally {
loadingEntities.value = false
}
})
let searchDebounce: ReturnType<typeof setTimeout> | null = null
const handleEntitySearch = (term: string) => {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(async () => {
if (!selectedTypeName.value) return
loadingEntities.value = true
try {
if (props.entityKind === 'component') {
const result = await loadComposants({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else if (props.entityKind === 'piece') {
const result = await loadPieces({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else {
const result = await loadProducts({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
entities.value = result?.data?.items || []
}
} finally {
loadingEntities.value = false
}
}, 300)
}
const handleClose = () => {
resetState()
emit('close')
}
const handleConfirm = () => {
if (!selectedTypeId.value) return
emit('confirm', {
entityId: selectedEntityId.value || undefined,
modelTypeId: selectedTypeId.value,
modelTypeName: selectedTypeName.value,
})
resetState()
emit('close')
}
const resetState = () => {
selectedTypeId.value = ''
selectedEntityId.value = ''
entities.value = []
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<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
<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"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
Aucun composant associé à cette machine.
</div>
<div v-else class="space-y-2">
<div v-for="component in components" :key="component.id">
<ComponentHierarchy
:components="[component]"
:is-edit-mode="isEditMode"
:show-delete="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@update="$emit('update-component', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('remove-component', component.linkId || component.id)"
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
/>
</div>
</div>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-component')"
>
Ajouter un composant
</button>
</div>
</div>
</template>
<script setup lang="ts">
import ComponentHierarchy from '~/components/ComponentHierarchy.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
defineProps<{
components: any[]
isEditMode: boolean
collapsed: boolean
collapseToggleToken: number
}>()
defineEmits<{
'toggle-collapse': []
'update-component': [component: any]
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
'add-component': []
'remove-component': [linkId: string]
'fill-entity': [linkId: string, modelTypeId: string]
}>()
</script>

View File

@@ -0,0 +1,112 @@
<template>
<section class="space-y-3">
<h3 class="text-sm font-semibold">
Définitions des champs personnalisés
</h3>
<p v-if="!fields.length" class="text-xs text-gray-500">
Aucun champ personnalisé défini. Cliquez sur « Ajouter » pour en créer un.
</p>
<ul v-else class="space-y-2" role="list">
<li
v-for="(field, index) in fields"
:key="field.uid"
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
:class="reorderClass(index)"
draggable="true"
@dragstart="onDragStart(index, $event)"
@dragenter="onDragEnter(index)"
@dragover.prevent="onDragEnter(index)"
@drop.prevent="onDrop(index)"
@dragend="onDragEnd"
>
<div class="flex items-start gap-3">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
title="Réordonner"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
>
<select v-model="field.type" class="select select-bordered select-sm">
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
Obligatoire
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-sm h-20"
placeholder="Option 1&#10;Option 2"
/>
</div>
<button
type="button"
class="btn btn-ghost btn-xs btn-square text-error"
@click="$emit('remove-field', index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</li>
</ul>
<button type="button" class="btn btn-outline btn-sm" @click="$emit('add-field')">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter un champ
</button>
</section>
</template>
<script setup lang="ts">
import type { MachineCustomFieldEditorField } from '~/composables/useMachineCustomFieldDefs'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
defineProps<{
fields: MachineCustomFieldEditorField[]
saving: boolean
reorderClass: (index: number) => string
onDragStart: (index: number, event: DragEvent) => void
onDragEnter: (index: number) => void
onDrop: (index: number) => void
onDragEnd: () => void
}>()
defineEmits<{
'add-field': []
'remove-field': [index: number]
}>()
</script>

View File

@@ -0,0 +1,221 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="card-title">Champs personnalisés</h2>
<p class="text-xs text-gray-500">
Champs personnalisés propres à cette machine.
</p>
</div>
<span v-if="visibleCustomFields.length" class="badge badge-outline">
{{ visibleCustomFields.length }} champ{{ visibleCustomFields.length > 1 ? 's' : '' }}
</span>
</div>
<!-- View mode: display values -->
<template v-if="!isEditMode">
<div v-if="visibleCustomFields.length" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<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">
{{ formatValueForDisplay(field) }}
</div>
</div>
</div>
<p v-else class="text-xs text-gray-500">
Aucun champ personnalisé défini pour cette machine.
</p>
</template>
<!-- Edit mode: definition management + value editing -->
<template v-else>
<p v-if="!customFields.length" class="text-xs text-gray-500">
Aucun champ personnalisé défini.
</p>
<div v-else class="space-y-3">
<div
v-for="(field, index) in customFields"
:key="field.id || field.name || index"
class="border border-base-200 rounded-md p-3 space-y-2"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 space-y-2">
<!-- Definition fields -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<input
:value="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
/>
<select
:value="field.type || 'text'"
class="select select-bordered select-sm"
@change="handleDefinitionUpdate(field, 'type', ($event.target as HTMLSelectElement).value)"
>
<option value="text">Texte</option>
<option value="number">Nombre</option>
<option value="select">Liste</option>
<option value="boolean">Oui/Non</option>
<option value="date">Date</option>
</select>
<div class="flex items-center gap-2 text-sm">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="!!field.required"
@change="handleDefinitionUpdate(field, 'required', ($event.target as HTMLInputElement).checked)"
/>
Obligatoire
</div>
</div>
<!-- Options for select type -->
<textarea
v-if="(field.type || 'text') === 'select'"
:value="field.optionsText || (Array.isArray(field.options) ? field.options.join('\n') : '')"
class="textarea textarea-bordered textarea-sm h-20 w-full"
placeholder="Option 1&#10;Option 2"
@blur="handleOptionsUpdate(field, ($event.target as HTMLTextAreaElement).value)"
></textarea>
<!-- Value editing -->
<div class="pt-1 border-t border-base-200">
<label class="label py-0">
<span class="label-text text-xs text-base-content/60">Valeur</span>
</label>
<input
v-if="!field.type || field.type === 'text'"
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Valeur..."
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/>
<input
v-else-if="field.type === 'number'"
:value="field.value ?? ''"
type="number"
class="input input-bordered input-sm w-full"
placeholder="Valeur..."
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/>
<select
v-else-if="field.type === 'select'"
:value="field.value ?? ''"
class="select select-bordered select-sm w-full"
@change="onSelectChange(field, ($event.target as HTMLSelectElement).value)"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<label
v-else-if="field.type === 'boolean'"
class="flex items-center gap-3 cursor-pointer py-1"
>
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
:checked="String(field.value).toLowerCase() === 'true'"
@change="onBooleanChange(field, ($event.target as HTMLInputElement).checked)"
>
<span
class="text-sm"
:class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'"
>
{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}
</span>
</label>
<input
v-else-if="field.type === 'date'"
:value="field.value ?? ''"
type="date"
class="input input-bordered input-sm w-full"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/>
</div>
</div>
<button
type="button"
class="btn btn-ghost btn-sm btn-square flex-shrink-0 text-error"
title="Supprimer ce champ"
@click="$emit('delete-field', field.id || field.customFieldId)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<button
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-field')"
>
Ajouter un champ personnalisé
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideTrash from '~icons/lucide/trash'
import { formatValueForDisplay } from '~/shared/utils/customFields'
defineProps<{
customFields: any[]
visibleCustomFields: any[]
isEditMode: boolean
}>()
const emit = defineEmits<{
'set-custom-field-value': [field: any, value: unknown]
'update-custom-field': [field: any]
'add-field': []
'delete-field': [fieldId: string]
'update-field-definition': [fieldId: string, data: Record<string, unknown>]
}>()
const handleDefinitionUpdate = (field: any, key: string, value: unknown) => {
const fieldId = field.id || field.customFieldId
if (!fieldId) return
emit('update-field-definition', fieldId, { ...field, [key]: value })
}
const handleOptionsUpdate = (field: any, raw: string) => {
const fieldId = field.id || field.customFieldId
if (!fieldId) return
const options = raw.split('\n').map((o: string) => o.trim()).filter((o: string) => o.length > 0)
emit('update-field-definition', fieldId, { ...field, options })
}
const onSelectChange = (field: any, value: string) => {
emit('set-custom-field-value', field, value)
emit('update-custom-field', field)
}
const onBooleanChange = (field: any, checked: boolean) => {
emit('set-custom-field-value', field, checked ? 'true' : 'false')
emit('update-custom-field', field)
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<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"
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>
<script setup lang="ts">
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 { canEdit } = usePermissions()
const props = defineProps<{
title: string
description?: string
siteName?: string
siteColor?: string
reference?: string
isEditMode: boolean
}>()
defineEmits<{
'toggle-edit': []
'open-print': []
}>()
const siteStyle = computed(() => {
if (!props.siteColor) return {}
return {
borderColor: props.siteColor + '60',
backgroundColor: props.siteColor + '25',
color: props.siteColor,
}
})
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div class="card bg-base-100 shadow-sm mt-6">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<div>
<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">
{{ files.length }} fichier{{ files.length > 1 ? 's' : '' }} sélectionné{{ files.length > 1 ? 's' : '' }}
</span>
</div>
<DocumentUpload
v-if="isEditMode"
:model-value="files"
@update:model-value="$emit('update:files', $event)"
title="Déposer des fichiers pour la machine"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="$emit('files-added', $event)"
/>
<div v-if="documents.length" class="space-y-2">
<div
v-for="doc in documents"
:key="doc.id"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(doc)"
>
<img
v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
:src="doc.fileUrl || doc.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${doc.name}`"
>
<iframe
v-else-if="shouldInlinePdf(doc)"
:src="documentPreviewSrc(doc)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(doc).component"
class="h-6 w-6"
:class="documentIcon(doc).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">{{ doc.name }}</div>
<div class="text-xs text-gray-500">
{{ doc.mimeType || 'Inconnu' }} &bull; {{ formatSize(doc.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(doc)"
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="$emit('preview', doc)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="$emit('download', doc)">
Télécharger
</button>
<button
v-if="isEditMode"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="uploading"
@click="$emit('remove', doc.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else class="text-xs text-gray-500">Aucun document lié à cette machine.</p>
</div>
</div>
</template>
<script setup lang="ts">
import DocumentUpload from '~/components/DocumentUpload.vue'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
documentIcon,
} from '~/shared/utils/documentDisplayUtils'
defineProps<{
documents: any[]
isEditMode: boolean
uploading: boolean
files: File[]
}>()
defineEmits<{
'update:files': [files: File[]]
'files-added': [files: File[]]
'preview': [doc: any]
'download': [doc: any]
'remove': [documentId: string]
}>()
</script>

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