Compare commits

..

339 Commits

Author SHA1 Message Date
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
9c8aecec93 feat : add Docker production deployment
Some checks failed
Build & Push Docker Image / build (push) Failing after 8s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:08:32 +02:00
Matthieu
476060cf7d WIP 2026-03-31 17:57:59 +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
1b1dab65b6 feat(constructeur) : add SearchFilter on ConstructeurLink entities
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:31:54 +02:00
Matthieu
5fff226f84 fix(constructeur) : fix remaining references after ConstructeurLink migration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:50:05 +02:00
Matthieu
34b0d9225c test(constructeur) : update test helpers and MCP tests for ConstructeurLinks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:30:46 +02:00
Matthieu
691f632be0 refactor(mcp) : update MCP tools to use ConstructeurLinks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:29:10 +02:00
Matthieu
43fafc2251 refactor(conversion) : update category conversion to use ConstructeurLink tables
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:26:30 +02:00
Matthieu
0ad5815659 refactor(versioning) : update EntityVersionService to use ConstructeurLinks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:25:20 +02:00
Matthieu
a249a5b785 refactor(audit) : update audit subscribers to use ConstructeurLinks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:23:35 +02:00
Matthieu
d85272208a refactor(machines) : update structure controller to use ConstructeurLinks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:19:03 +02:00
Matthieu
26be0b655d refactor(constructeur) : replace ManyToMany with OneToMany to ConstructeurLink entities
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:12:17 +02:00
Matthieu
2d33c97449 feat(constructeur) : add 4 ConstructeurLink pivot entities with supplierReference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:09:07 +02:00
Matthieu
03c2451990 feat(reference-auto) : extend auto-reference to composants + formula builder UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:59:42 +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
3f6ce153bb feat(reference-auto) : add automatic reference generation for pieces
ModelType defines a formula with placeholders ({serie}{diametre}{type}).
ReferenceAutoGenerator resolves it from CustomFieldValues with trim+uppercase normalisation.
ReferenceAutoSubscriber (onFlush) recalculates on Piece/CFV insert/update/delete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:58:53 +01:00
Matthieu
d568961eb3 feat(machine) : single save button + link versioning with restore
Backend:
- Enrich machine snapshot with componentLinks/pieceLinks/productLinks
- Detect link add/remove in MachineAuditSubscriber onFlush
- Add link diff comparison in restore preview
- Add link restoration in applyRestore for machines
- Add integrity warnings for missing linked entities

Frontend (submodule update):
- Single save button replacing auto-save-on-blur
- Link versioning display in version list and restore modal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:51:58 +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
9299a46c8b feat(versioning) : add entity versioning with numbered versions and restore
Backend:
- Migration: version column on audit_logs and machines
- AuditLog, Machine, Composant, Piece, Product: version + skipAudit properties
- AbstractAuditSubscriber: auto-increment version, skip on restore, fix decimal diff
- Enriched snapshots with slots, custom fields and version number
- AuditLogRepository: findVersionHistory, findByVersion
- EntityVersionService: list, preview, restore with skeleton/integrity checks
- EntityVersionController: REST endpoints for all 4 entity types
- 11 tests covering list, preview, restore, auth

Frontend: update submodule pointer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:01:56 +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
162c6ece71 chore : untrack auto-generated config/reference.php
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:39:29 +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
3f93781e16 docs(versioning) : add entity versioning implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:04:30 +01:00
a07145c78f chore(submodule) : update frontend pointer (fix form data loss on error)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:03:04 +01:00
586b7bb91d docs(versioning) : add entity versioning design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:32:53 +01:00
3a75269323 fix(composant) : replace unique constraint from name to reference validation
Remove DB unique index on composants.name and add Symfony UniqueEntity
validation on reference field with explicit error message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:12:19 +01:00
Matthieu
66fa0a506c docs : update CLAUDE.md with project conventions and architecture
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:00:53 +01:00
Matthieu
9b35023879 chore(release) : bump version to 1.9.4 + update frontend pointer (detail views)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 10:15:30 +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
5463cde38b chore(submodule) : update frontend pointer (machine history section)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:38:09 +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
7eb6def192 chore(submodule) : update frontend pointer (display ref in selects)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:21:46 +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
9d75653624 chore(submodule) : update frontend pointer (fix server-search client filter)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:19:33 +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
fd69d6a63e chore(submodule) : update frontend pointer (select search on name + reference)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:12:07 +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
172ec78c5f chore(submodule) : update frontend pointer (multi-field search)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:57:34 +01:00
Matthieu
d70b9086d5 feat(search) : add MultiSearchFilter for OR search on name + reference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:57:30 +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
73ebd6902d chore(release) : bump version to 1.9.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:17:39 +01:00
Matthieu
ded1f7a8b6 chore(submodule) : update frontend pointer (comment attachments + fixes)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:41 +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
3b35598b07 fix(structure) : stabilize piece/component/product ordering in machines
All findBy(['machine' => ...]) queries now sort by createdAt ASC.
Without explicit ORDER BY, PostgreSQL returned rows in heap order which
changed on every INSERT, causing the displayed order to shuffle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:02:36 +01:00
Matthieu
06ce9fb1f2 chore(config) : update reference.php + remove disabled config files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:49:59 +01:00
Matthieu
8851f22e4e feat(ops) : add custom field audit and restore commands
- app:check-missing-custom-field-values — diagnostic des valeurs perdues
- app:restore-piece-custom-field-values — restauration générale
- app:restore-recoverable-piece-custom-field-values — restauration ciblée
  depuis l'audit log (dry-run par défaut, --apply pour exécuter)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:49:53 +01:00
Matthieu
330b9376f6 feat(comments) : add file attachments on comments
Comments can now have documents attached via multipart/form-data upload.
New endpoint GET /api/documents/comment/{id} to list a comment's files.
Document entity gains a comment relation with cascade remove.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:49:46 +01:00
Matthieu
4468fd7cdf fix(custom-fields) : match by orderIndex to prevent value loss on rename
When a ModelType's custom field was renamed without sending the field ID,
the service would create a new CustomField instead of reusing the existing
one, orphaning all CustomFieldValues. Now matches by orderIndex as fallback
before name, preserving the link to existing values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:36:59 +01:00
Matthieu
509c4d2247 test(data-integrity) : add 10 tests for data loss prevention
Tests cover:
- Clone: CustomFieldValue references cloned definitions, not source
- Clone: values are preserved after cloning
- Slots: 404 on non-existent piece, 422 on wrong type, success on correct type
- Conversion: blocked when slots have filled data, allowed when empty
- CustomField: rejects orphan creation, accepts existing field by ID

Also removes legacy JSON structure check (column no longer exists
after normalization) — replaced by slot table checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:33:18 +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
043f6b1ce6 fix(data-integrity) : prevent data loss in clone, slots, conversion and custom fields
- Clone: CustomFieldValue now references cloned CustomField, not source
- Slots: validate piece type matches slot requirement + 404 on missing piece
- Conversion: check slot tables before allowing category conversion + clean orphan skeleton requirements
- CustomFieldValue: prevent creation of orphan CustomField without target entity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:15:05 +01:00
Matthieu
d5a43fc9bb chore(submodule) : update frontend pointer (changelog v1.10.0)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:14:25 +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
0de2aba538 chore(submodule) : update frontend pointer (document types feature)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:57:25 +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
5ec6e49af2 feat(documents) : accept type on upload + expose in query controller + PATCH tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:20:39 +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
8d920d5f65 feat(documents) : add migration for type column with data classification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:14:33 +01:00
Matthieu
342b0afdbb feat(documents) : add DocumentType enum and type column on entity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:09:49 +01:00
Matthieu
2043e5b643 fix(constructeurs) : persist supplier removal on Piece, Composant and Product
- Frontend: always include constructeurs key in PATCH payload even when
  empty, so merge-patch+json actually clears the relation
- Backend: add setConstructeurs() on Piece and Product entities (Composant
  already had it) so API Platform can replace the ManyToMany collection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:54:08 +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
21e5ad5381 chore(submodule) : update frontend pointer (redirect to edit after creation)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:30:18 +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
53b6abc9a8 fix(composant) : persist piece/product/subcomponent selections on creation
The ComposantProcessor now reads the structure payload from the frontend
and applies selectedPieceId/selectedProductId/selectedComponentId to the
scaffolded slots, so user selections are actually saved to the database.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:04:19 +01:00
Matthieu
826dae7712 fix(composant) : scaffold skeleton slots on creation + explicit unique constraint errors
- Add ComposantProcessor: initializes piece/product/subcomponent slots
  from ModelType skeleton requirements when a composant is created
- UniqueConstraintSubscriber: priority 256, French error messages,
  constraint name detection for specific feedback
- Migration: scaffold missing slots for existing composants in prod

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:48:23 +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
38777b7de0 fix(custom-fields) : prevent data loss on ModelType save + restoration scripts
Backend: match existing CustomField by name as fallback when ID is not provided,
preventing deletion and recreation of field definitions (which cascade-deletes values).

Includes restoration/migration scripts for prod:
- restore-custom-field-values.php: restores piece values from audit logs
- migrate-orphaned-custom-fields.php: migrates values from orphaned CFs
- fix-prod-all.php: combined fix (migrate + restore + cleanup)
- fix-prod-recreate-and-migrate.php: full fix (recreate missing CFs + migrate + restore)
- check-prod-*.php: diagnostic scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:24:37 +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
add3a9a21f fix(mcp) : return CallToolResult to prevent structuredContent serialization issue
Tools now return CallToolResult directly instead of Content arrays,
preventing the MCP SDK from auto-generating structuredContent as a
JSON array (which Claude Code rejects — expects a JSON object/record).
Also adds Accept header to test helpers and SSE response parsing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:24:04 +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
f965affc94 feat(mcp) : add MCP resources, documentation, and .mcp.json config
- 3 MCP resources: schema, roles, stats
- docs/mcp/README.md with full user guide (config, tools catalogue, workflows)
- .mcp.json for Claude Code stdio transport
- Design spec and implementation plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:49:00 +01:00
Matthieu
4340a0e13e feat(mcp) : add business tools — search, history, comments, custom fields, documents, model types
- search_inventory: global search across all 6 entity types
- get_entity_history + get_activity_log: audit trail access
- 4 comment tools: list, create, resolve, unresolved count
- 3 custom field tools: list values, upsert, delete
- 2 document tools: list, delete (upload via REST only)
- 6 model type tools: list, get, create, update, delete, sync
- 69 MCP tests pass total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:00:37 +01:00
Matthieu
bd7259ed05 feat(mcp) : add Slots, Machine Links, Structure, and Clone tools
- list_slots + update_slots for composant/piece slots
- list/add/update/remove machine links (component, piece, product)
- get_machine_structure with full hierarchy
- clone_machine with all links and custom fields
- 52 MCP tests pass total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:49:55 +01:00
Matthieu
2f173e766d feat(mcp) : add CRUD tools for Pieces, Composants, Machines
- 5 tools each: list, get, create, update, delete
- Piece: includes typePiece, constructeurs, prix (string)
- Composant: includes typeComposant, constructeurs, prix (string)
- Machine: includes site (required), constructeurs
- 40 MCP tests pass total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:38:55 +01:00
Matthieu
4f1e136dc5 feat(mcp) : add CRUD tools for Sites, Constructeurs, Products
- 5 tools each: list, get, create, update, delete
- McpToolHelper extracted to AbstractApiTestCase for reuse
- DashboardStatsToolTest simplified to use base helpers
- 22 MCP tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:31:15 +01:00
Matthieu
e335f4c24c feat(mcp) : add stdio auth, dashboard stats PoC tool, and helper trait
- McpStdioAuthSubscriber for console transport auth via env vars
- DashboardStatsTool as PoC (validates MCP protocol flow)
- McpToolHelper trait for shared pagination/error utilities
- Key learning: #[McpTool] must be on CLASS, not method for __invoke

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:18:09 +01:00
Matthieu
46ea3ca8ad feat(mcp) : re-enable MCP bundle config after package install
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:09:29 +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
442 changed files with 84893 additions and 2791 deletions

6
.claude/settings.json Normal file
View File

@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"security-guidance@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true
}
}

0
.codex Normal file
View File

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
.git
.gitea
.env.local
.env.test
docker/
deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh
deploy/docker/.env.example
frontend/node_modules
frontend/.nuxt
frontend/.output
var/
vendor/
LOG/
docs/
tests/
scripts/
*.sql
*.xlsx
*.png
*.md
!composer.lock
!symfony.lock
!frontend/package-lock.json

View File

@@ -0,0 +1,30 @@
name: Build & Push Docker Image
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
- name: Build Docker image
run: |
docker build \
-f deploy/docker/Dockerfile.prod \
-t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/inventory:latest \
.
- name: Push Docker image
run: |
docker push gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }}
docker push gitea.malio.fr/malio-dev/inventory:latest

13
.gitignore vendored
View File

@@ -1,4 +1,3 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
@@ -23,25 +22,18 @@
###> docker ###
docker/.env.docker.local
###< docker ###
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
###> migration archives ###
/_archives/
###< migration archives ###
###> temp files ###
*.sql
*.sql.gz
*.har
FEATURE_IDEAS.md
bin/.phpunit.result.cache
###< temp files ###
###> frontend ###
/frontend/
###< frontend ###
###> ide ###
/.idea/
###< ide ###
@@ -49,3 +41,4 @@ FEATURE_IDEAS.md
###> wsl ###
*:Zone.Identifier
###< wsl ###
config/reference.php

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

12
.mcp.json Normal file
View File

@@ -0,0 +1,12 @@
{
"mcpServers": {
"inventory": {
"type": "http",
"url": "http://inventory.malio-dev.fr/_mcp",
"headers": {
"X-Profile-Id": "admin-default-profile",
"X-Profile-Password": "A123"
}
}
}
}

View File

@@ -18,13 +18,22 @@ Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
| Auth | Session-based (cookies, pas JWT) | |
| Containers | Docker Compose | |
## Glossaire Métier
Voir `docs/GLOSSAIRE_METIER.md` — glossaire complet du domaine métier (concepts, workflows utilisateur, correspondance métier↔code). À consulter pour comprendre le "pourquoi" derrière le code.
## Project Structure
```
Inventory/ # Backend Symfony (repo principal)
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
│ └── Trait/ # CuidEntityTrait (génération d'ID CUID)
├── src/Controller/ # Controllers custom (session, comments, audit…)
├── src/EventSubscriber/ # Audit subscribers (onFlush)
├── src/Service/ # Services métier (sync, conversion, storage…)
├── src/Enum/ # Enums PHP (DocumentType, ModelCategory)
├── src/DTO/ # Data Transfer Objects (sync workflow)
├── src/Filter/ # Filtres API Platform custom
├── src/Command/ # Commandes Symfony CLI (compress-pdf, create-profile…)
├── config/ # Config Symfony
├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL)
├── docker/ # Dockerfile + .env.docker
@@ -34,7 +43,7 @@ Inventory/ # Backend Symfony (repo principal)
├── pre-commit, commit-msg # Git hooks
├── makefile # Commandes Docker/dev
├── 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/components/ # Composants Vue (auto-imported)
│ ├── app/composables/ # Composables Vue
@@ -58,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)
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 build # Build production
npm run lint:fix # ESLint fix
@@ -100,7 +109,7 @@ Exemples :
### Submodule Workflow
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
3. Push les deux repos
@@ -111,7 +120,8 @@ Le frontend est un submodule git. Lors d'un commit frontend :
#### Entités de normalisation (slots & skeleton requirements)
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
- **Slots** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
- **Slots composant** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
- **Slots pièce** (données réelles d'une pièce) : `PieceProductSlot`
- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
### Patterns
@@ -127,6 +137,8 @@ Remplacent les anciennes colonnes JSON `structure` et `productIds` par des table
- `MachineCustomFieldsController``/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
- `CustomFieldValueController``/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
- `ComposantPieceSlotController``/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant.
- `ComposantProductSlotController``/api/composant-product-slots/{id}` (PATCH) : mise à jour des slots produit d'un composant.
- `ComposantSubcomponentSlotController``/api/composant-subcomponent-slots/{id}` (PATCH) : mise à jour des slots sous-composant d'un composant.
- `SessionProfileController``/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user).
- `SessionProfilesController``/api/session/profiles` (GET) : liste des profils disponibles pour la session.
- `AdminProfileController``/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN).
@@ -136,6 +148,8 @@ Remplacent les anciennes colonnes JSON `structure` et `productIds` par des table
- `DocumentQueryController``/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit.
- `DocumentServeController``/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers.
- `ModelTypeConversionController``/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType.
- `ModelTypeSyncController``/api/model_types/{id}/sync-preview|sync-confirm` (POST) : prévisualisation et application de sync ModelType→Composants.
- `EntityVersionController``/api/{entity}/{id}/versions` (GET), `/api/{entity}/{id}/versions/{version}/restore` (POST) : historique de versions numérotées et restauration.
- `HealthCheckController``/api/health` (GET) : health check.
### Custom Fields — Architecture
@@ -143,11 +157,30 @@ Remplacent les anciennes colonnes JSON `structure` et `productIds` par des table
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
### Normalisation JSON → Tables (architecture slots)
Les anciennes colonnes JSON `structure` et `productIds` des Composants ont été remplacées par des tables relationnelles :
- **ModelType** définit le squelette via `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
- **Composant** stocke les données réelles via `ComposantPieceSlot`, `ComposantProductSlot`, `ComposantSubcomponentSlot`
- Chaque slot référence son skeleton requirement (`skeletonRequirement` FK) + l'entité sélectionnée + position
### Enums (`src/Enum/`)
- `DocumentType` — types de documents (photo, schéma, facture, etc.)
- `ModelCategory` — catégories de ModelType
### Services (`src/Service/`)
- `ModelTypeSyncService` — synchronise les skeleton requirements d'un ModelType vers les composants existants
- `ModelTypeCategoryConversionService` — conversion de catégorie d'un ModelType
- `SkeletonStructureService` — gestion de la structure skeleton (requirements)
- `DocumentStorageService` — stockage et gestion des fichiers documents
- `PdfCompressorService` — compression des PDFs uploadés
- `EntityVersionService` — gestion des versions numérotées (snapshot, restore) pour machines, pièces, composants, produits
- `ReferenceAutoGenerator` — génération automatique de références pour pièces et composants à partir de formules ModelType
- `src/Service/Sync/` — stratégies de sync par type de slot (tagged `app.sync_strategy`)
### DTOs (`src/DTO/`)
- `SyncConfirmation`, `SyncPreviewResult`, `SyncExecutionResult` — objets de transfert pour le workflow de sync ModelType
### Filters (`src/Filter/`)
- `MultiSearchFilter` — filtre API Platform pour recherche OR sur plusieurs champs (ex: name + reference)
### EventSubscribers notables (non-audit)
- `PieceProductSyncSubscriber` — sync automatique des PieceProductSlots
- `UniqueConstraintSubscriber` — traduit les erreurs de contrainte unique PG en messages utilisateur lisibles
- `ReferenceAutoSubscriber` — recalcule les références auto des pièces/composants quand les CustomFieldValues changent (onFlush)
### Rôles (hiérarchie)
```
@@ -223,7 +256,7 @@ make test-setup # Créer/mettre à jour le schéma test
### Pattern de test
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
## URLs Locales

View File

@@ -166,7 +166,7 @@ Inventory/ # Backend Symfony (repo principal)
├── docker/ # Dockerfile + config Docker
├── makefile # Commandes de dev raccourcies
├── 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/components/ # Composants Vue réutilisables
├── app/composables/ # Logique métier partagée (appels API, états)
@@ -289,9 +289,9 @@ Le hook `pre-commit` s'exécute automatiquement avant chaque commit :
### 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
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)
- **[RELEASE.md](RELEASE.md)** : processus de release et versioning
- **[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

29
TODO.md Normal file
View File

@@ -0,0 +1,29 @@
# TODO — MCP Inventory
## Bugs / Améliorations prioritaires
### sync_model_type ne fonctionne pas via MCP
Le tool `sync_model_type` attend un paramètre `structure` de type `array` (objet JSON imbriqué), mais le SDK MCP PHP ne supporte pas les objets complexes en paramètres — il reçoit un string au lieu d'un array.
**Solutions possibles :**
1. Accepter `structure` comme `string` (JSON encodé) et le décoder manuellement dans le tool
2. Créer des tools séparés : `add_product_requirement`, `add_custom_field_requirement`, `remove_requirement` au lieu d'un seul sync
3. Passer par des sous-paramètres plats (productTypeIds, customFieldNames, etc.)
**Impact :** L'IA ne peut pas ajouter de produits ni de champs personnalisés à une catégorie (ModelType) via MCP. Contournement actuel : passer par l'API REST.
---
### Resources MCP en erreur
Les 3 Resources (`SchemaResource`, `RolesResource`, `StatsResource`) produisent `[error] Failed to process MCP attribute`. Elles ne bloquent pas les tools mais ne sont pas exposées aux clients.
**Cause probable :** Incompatibilité du format `#[McpResource]` avec le SDK v0.4 / bundle v0.6.
---
## Améliorations futures
- [ ] Documentation utilisateur `docs/mcp/README.md` — guide d'utilisation pour les différents clients (Claude Desktop, ChatGPT, Codex)
- [ ] Mettre à jour CLAUDE.md avec la section MCP
- [ ] Ajouter le tool `upload_document` (upload de fichiers via MCP)
- [ ] Tester la compatibilité avec ChatGPT Desktop et Claude Desktop via tunnel

View File

@@ -1 +1 @@
1.9.1
1.9.4

View File

@@ -12,7 +12,6 @@
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8",
"phpdocumentor/reflection-docblock": "^5.6",
@@ -33,8 +32,7 @@
"symfony/twig-bundle": "8.0.*",
"symfony/uid": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*",
"vich/uploader-bundle": "^2.9"
"symfony/yaml": "8.0.*"
},
"config": {
"allow-plugins": {

536
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b15a7808211e724ca29dd78602df3aab",
"content-hash": "2db01f705a09cf38007a2baa3b078e49",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2361,259 +2361,6 @@
},
"time": "2025-10-26T09:35:14+00:00"
},
{
"name": "jms/metadata",
"version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/metadata.git",
"reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/schmittjoh/metadata/zipball/554319d2e5f0c5d8ccaeffe755eac924e14da330",
"reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330",
"shasum": ""
},
"require": {
"php": "^7.2|^8.0"
},
"require-dev": {
"doctrine/cache": "^1.0|^2.0",
"doctrine/coding-standard": "^8.0",
"mikey179/vfsstream": "^1.6.7",
"phpunit/phpunit": "^8.5.42|^9.6.23",
"psr/container": "^1.0|^2.0",
"symfony/cache": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0",
"symfony/dependency-injection": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Metadata\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Johannes M. Schmitt",
"email": "schmittjoh@gmail.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "Class/method/property metadata management in PHP",
"keywords": [
"annotations",
"metadata",
"xml",
"yaml"
],
"support": {
"issues": "https://github.com/schmittjoh/metadata/issues",
"source": "https://github.com/schmittjoh/metadata/tree/2.9.0"
},
"time": "2025-11-30T20:12:26+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
"infection/infection": "^0.29",
"lcobucci/clock": "^3.2",
"lcobucci/coding-standard": "^11.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.10.7",
"phpstan/phpstan-deprecation-rules": "^1.1.3",
"phpstan/phpstan-phpunit": "^1.3.10",
"phpstan/phpstan-strict-rules": "^1.5.0",
"phpunit/phpunit": "^11.1"
},
"suggest": {
"lcobucci/clock": ">= 3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com",
"role": "Developer"
}
],
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
"keywords": [
"JWS",
"jwt"
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-10-17T11:30:53+00:00"
},
{
"name": "lexik/jwt-authentication-bundle",
"version": "v3.2.0",
"source": {
"type": "git",
"url": "https://github.com/lexik/LexikJWTAuthenticationBundle.git",
"reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lexik/LexikJWTAuthenticationBundle/zipball/60df75dc70ee6f597929cb2f0812adda591dfa4b",
"reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"lcobucci/jwt": "^5.0",
"php": ">=8.2",
"symfony/clock": "^6.4|^7.0|^8.0",
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.4|^3.0",
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/security-bundle": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^1.0|^2.0|^3.0"
},
"require-dev": {
"api-platform/core": "^3.0|^4.0",
"rector/rector": "^1.2",
"symfony/browser-kit": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/dom-crawler": "^6.4|^7.0|^8.0",
"symfony/filesystem": "^6.4|^7.0|^8.0",
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
"symfony/phpunit-bridge": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0"
},
"suggest": {
"gesdinet/jwt-refresh-token-bundle": "Implements a refresh token system over Json Web Tokens in Symfony",
"spomky-labs/lexik-jose-bridge": "Provides a JWT Token encoder with encryption support"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Lexik\\Bundle\\JWTAuthenticationBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jeremy Barthe",
"email": "j.barthe@lexik.fr",
"homepage": "https://github.com/jeremyb"
},
{
"name": "Nicolas Cabot",
"email": "n.cabot@lexik.fr",
"homepage": "https://github.com/slashfan"
},
{
"name": "Cedric Girard",
"email": "c.girard@lexik.fr",
"homepage": "https://github.com/cedric-g"
},
{
"name": "Dev Lexik",
"email": "dev@lexik.fr",
"homepage": "https://github.com/lexik"
},
{
"name": "Robin Chalas",
"email": "robin.chalas@gmail.com",
"homepage": "https://github.com/chalasr"
},
{
"name": "Lexik Community",
"homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle/graphs/contributors"
}
],
"description": "This bundle provides JWT authentication for your Symfony REST API",
"homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle",
"keywords": [
"Authentication",
"JWS",
"api",
"bundle",
"jwt",
"rest",
"symfony"
],
"support": {
"issues": "https://github.com/lexik/LexikJWTAuthenticationBundle/issues",
"source": "https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v3.2.0"
},
"funding": [
{
"url": "https://github.com/chalasr",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/lexik/jwt-authentication-bundle",
"type": "tidelift"
}
],
"time": "2025-12-20T17:47:00+00:00"
},
{
"name": "mcp/sdk",
"version": "v0.4.0",
@@ -5594,92 +5341,6 @@
],
"time": "2026-03-04T16:39:24+00:00"
},
{
"name": "symfony/mime",
"version": "v8.0.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "7576ce3b2b4d3a2a7fe7020a07a392065d6ffd40"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/7576ce3b2b4d3a2a7fe7020a07a392065d6ffd40",
"reference": "7576ce3b2b4d3a2a7fe7020a07a392065d6ffd40",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<3.2.2",
"phpdocumentor/type-resolver": "<1.4.0"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v8.0.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-11-16T10:17:21+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.0.0",
@@ -5906,93 +5567,6 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"symfony/polyfill-intl-normalizer": "^1.10"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Idn\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laurent Bassin",
"email": "laurent@bassin.info"
},
{
"name": "Trevor Rowbotham",
"email": "trevor.rowbotham@pm.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"idn",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",
@@ -8426,114 +8000,6 @@
],
"time": "2025-12-14T11:28:47+00:00"
},
{
"name": "vich/uploader-bundle",
"version": "v2.9.1",
"source": {
"type": "git",
"url": "https://github.com/dustin10/VichUploaderBundle.git",
"reference": "945939a04a33c0b78c5fbb7ead31533d85112df5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dustin10/VichUploaderBundle/zipball/945939a04a33c0b78c5fbb7ead31533d85112df5",
"reference": "945939a04a33c0b78c5fbb7ead31533d85112df5",
"shasum": ""
},
"require": {
"doctrine/persistence": "^3.0 || ^4.0",
"ext-simplexml": "*",
"jms/metadata": "^2.4",
"php": "^8.1",
"symfony/config": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/event-dispatcher-contracts": "^3.1",
"symfony/http-foundation": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/http-kernel": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/mime": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/property-access": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/string": "^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"conflict": {
"doctrine/annotations": "<1.12",
"league/flysystem": "<2.0"
},
"require-dev": {
"dg/bypass-finals": "^1.9",
"doctrine/common": "^3.0",
"doctrine/doctrine-bundle": "^2.7 || ^3.0",
"doctrine/mongodb-odm": "^2.4",
"doctrine/orm": "^2.13 || ^3.0",
"ext-sqlite3": "*",
"knplabs/knp-gaufrette-bundle": "dev-master",
"league/flysystem-bundle": "^2.4 || ^3.0",
"league/flysystem-memory": "^2.0 || ^3.0",
"matthiasnoback/symfony-dependency-injection-test": "^5.1 || ^6.0",
"mikey179/vfsstream": "^1.6.11",
"phpunit/phpunit": "^10.5 || ^11.5 || ^12.2",
"symfony/asset": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/browser-kit": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/doctrine-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/dom-crawler": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/form": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/phpunit-bridge": "^7.3",
"symfony/security-csrf": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/translation": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/twig-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/twig-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/validator": "^5.4.22 || ^6.0 || ^7.0 || ^8.0",
"symfony/var-dumper": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"suggest": {
"doctrine/doctrine-bundle": "For integration with Doctrine",
"doctrine/mongodb-odm-bundle": "For integration with Doctrine ODM",
"doctrine/orm": "For integration with Doctrine ORM",
"doctrine/phpcr-odm": "For integration with Doctrine PHPCR",
"knplabs/knp-gaufrette-bundle": "For integration with Gaufrette",
"league/flysystem-bundle": "For integration with Flysystem",
"liip/imagine-bundle": "To generate image thumbnails",
"oneup/flysystem-bundle": "For integration with Flysystem",
"symfony/asset": "To generate better links",
"symfony/form": "To handle uploads in forms",
"symfony/yaml": "To use YAML mapping"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Vich\\UploaderBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dustin Dobervich",
"email": "ddobervich@gmail.com"
}
],
"description": "Ease file uploads attached to entities",
"homepage": "https://github.com/dustin10/VichUploaderBundle",
"keywords": [
"file uploads",
"upload"
],
"support": {
"issues": "https://github.com/dustin10/VichUploaderBundle/issues",
"source": "https://github.com/dustin10/VichUploaderBundle/tree/v2.9.1"
},
"time": "2025-12-10T08:23:38+00:00"
},
{
"name": "webmozart/assert",
"version": "2.0.0",

View File

@@ -6,20 +6,20 @@ use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [
FrameworkBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
SecurityBundle::class => ['all' => true],
DoctrineBundle::class => ['all' => true],
DoctrineMigrationsBundle::class => ['all' => true],
NelmioCorsBundle::class => ['all' => true],
ApiPlatformBundle::class => ['all' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true],
FrameworkBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
SecurityBundle::class => ['all' => true],
DoctrineBundle::class => ['all' => true],
DoctrineMigrationsBundle::class => ['all' => true],
NelmioCorsBundle::class => ['all' => true],
ApiPlatformBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true],
McpBundle::class => ['all' => true],
];

View File

@@ -3,7 +3,10 @@ framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
session:
cookie_secure: auto
cookie_samesite: lax
cookie_httponly: true
#esi: true
#fragments: true

View File

@@ -1,4 +0,0 @@
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'

View File

@@ -4,3 +4,7 @@ framework:
policy: sliding_window
limit: 5
interval: '1 minute'
login:
policy: sliding_window
limit: 5
interval: '1 minute'

View File

@@ -27,12 +27,11 @@ security:
pattern: ^/api/session/profiles?$
security: false
# TODO: re-enable when symfony/ai-mcp-bundle is installed
# mcp:
# pattern: ^/_mcp
# stateless: true
# custom_authenticators:
# - App\Mcp\Security\McpHeaderAuthenticator
mcp:
pattern: ^/_mcp
stateless: true
custom_authenticators:
- App\Mcp\Security\McpHeaderAuthenticator
api:
pattern: ^/api
@@ -56,7 +55,7 @@ security:
- { path: ^/api/admin, roles: ROLE_ADMIN }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
# - { path: ^/_mcp, roles: ROLE_USER } # TODO: re-enable with MCP
- { path: ^/_mcp, roles: ROLE_USER }
- { path: ^/docs, roles: PUBLIC_ACCESS }
- { path: ^/contexts, roles: PUBLIC_ACCESS }
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }

File diff suppressed because it is too large Load Diff

View File

@@ -12,3 +12,7 @@ api_login_check:
controllers:
resource: routing.controllers
mcp:
resource: .
type: mcp

View File

@@ -18,8 +18,6 @@ services:
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/Mcp/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
@@ -36,10 +34,9 @@ services:
tags:
- { name: doctrine.event_subscriber }
# TODO: re-enable when symfony/ai-mcp-bundle is installed
# App\Mcp\Security\McpHeaderAuthenticator:
# arguments:
# $mcpAuthLimiter: '@limiter.mcp_auth'
App\Mcp\Security\McpHeaderAuthenticator:
arguments:
$mcpAuthLimiter: '@limiter.mcp_auth'
App\OpenApi\OpenApiDecorator:
decorates: 'api_platform.openapi.factory'
@@ -67,3 +64,8 @@ when@test:
autowire: true
autoconfigure: true
public: true
App\Service\ReferenceAutoGenerator:
autowire: true
autoconfigure: true
public: true

View File

@@ -0,0 +1,10 @@
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=change-me
# Database (use host.docker.internal to reach bare-metal PostgreSQL)
DATABASE_URL="postgresql://inventory_user:password@host.docker.internal:5432/inventory_prod?serverVersion=16&charset=utf8"
# CORS
CORS_ALLOW_ORIGIN='^https?://inventory\.malio-dev\.fr$'

View File

@@ -0,0 +1,83 @@
# --- Stage 1: Build backend ---
FROM php:8.4-cli AS backend-build
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
unzip curl git \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --no-scripts --no-interaction
COPY bin bin/
COPY config config/
COPY migrations migrations/
COPY public public/
COPY src src/
COPY templates templates/
COPY VERSION VERSION
RUN composer dump-autoload --optimize --no-dev
# --- Stage 2: Build frontend ---
FROM node:lts-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
ENV CI=1 \
NUXT_TELEMETRY_DISABLED=1 \
NUXT_PUBLIC_API_BASE_URL=/api \
NUXT_PUBLIC_APP_BASE=/
RUN npm run generate
# --- Stage 3: Production image ---
FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# PHP-FPM: forward worker output to stderr for docker logs
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
# Nginx: log to stdout/stderr
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log
# Remove default nginx site
RUN rm -f /etc/nginx/sites-enabled/default
# Configs
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/inventory.conf
# Backend from stage 1
COPY --from=backend-build /app /var/www/html
# Frontend from stage 2
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads \
&& chown -R www-data:www-data /var/www/html/var
WORKDIR /var/www/html
EXPOSE 80
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]

28
deploy/docker/deploy.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/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

@@ -0,0 +1,12 @@
services:
app:
image: gitea.malio.fr/malio-dev/inventory:${INVENTORY_IMAGE_TAG:-latest}
container_name: inventory-app
env_file: .env
ports:
- "8082:80"
volumes:
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped

36
deploy/docker/nginx.conf Normal file
View File

@@ -0,0 +1,36 @@
server {
listen 80;
server_name _;
root /var/www/html/frontend/.output/public;
index index.html;
access_log /dev/stdout;
error_log /dev/stderr;
location ^~ /api/ {
root /var/www/html/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/html/public;
try_files $uri =404;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_pass 127.0.0.1:9000;
internal;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT

View File

@@ -0,0 +1,13 @@
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;
}
}

299
doc/deployment-docker.md Normal file
View File

@@ -0,0 +1,299 @@
# Deploiement Docker — Inventory
## Pre-requis
### Docker
```bash
# Ubuntu
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER
```
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
### Nginx
```bash
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
```
### PostgreSQL
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
Il doit etre installe et accessible avant de deployer Inventory.
Creer la base de donnees pour Inventory :
```bash
cd /var/www/postgres
docker compose exec postgres psql -U admin
```
```sql
-- Si le user n'existe pas encore
CREATE USER malio WITH PASSWORD 'motdepasse';
-- Creer la base
CREATE DATABASE inventory_prod OWNER malio;
\q
```
---
## Premiere installation (nouvelle machine)
Guide complet pour mettre en ligne Inventory sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
### 1. Installer les pre-requis
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
### 2. Creer le dossier de deploiement
```bash
sudo mkdir -p /var/www/inventory
sudo chown -R $(whoami):$(whoami) /var/www/inventory
cd /var/www/inventory
```
### 3. Se connecter au registry Docker de Gitea
```bash
docker login gitea.malio.fr
```
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO`
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
### 4. Creer les fichiers de deploiement
Creer `docker-compose.yml` :
```yaml
services:
app:
image: gitea.malio.fr/malio-dev/inventory:${INVENTORY_IMAGE_TAG:-latest}
container_name: inventory-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
```
Creer `deploy.sh` :
```bash
#!/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..."
docker compose pull
echo "==> Starting container..."
docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Running migrations..."
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
VERSION=$(docker compose exec -T app cat VERSION)
echo "==> Deployed v${VERSION}"
```
Rendre executable :
```bash
chmod +x deploy.sh
```
### 5. Configurer l'environnement
Creer `.env` avec les variables suivantes :
```env
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=<generer avec: openssl rand -hex 32>
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/inventory_prod?serverVersion=16&charset=utf8"
# CORS
CORS_ALLOW_ORIGIN='^https?://inventory\.malio-dev\.fr$'
```
### 6. Creer le dossier uploads
```bash
mkdir -p uploads
```
### 7. Configurer Nginx systeme
Creer `/etc/nginx/sites-available/inventory.conf` :
```nginx
server {
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;
}
}
```
Activer le site :
```bash
sudo ln -sf /etc/nginx/sites-available/inventory.conf /etc/nginx/sites-enabled/inventory.conf
sudo nginx -t && sudo systemctl reload nginx
```
### 8. Deployer
```bash
./deploy.sh
```
### 9. Importer les donnees (optionnel)
Si tu as un dump SQL a importer :
```bash
# Depuis ton PC, envoyer le dump vers le serveur
scp inventory.sql user@serveur:/tmp/inventory.sql
# Sur le serveur, vider la base puis importer
cd /var/www/postgres
docker compose exec -T postgres psql -U malio inventory_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U malio inventory_prod < /tmp/inventory.sql
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
cd /var/www/inventory
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
# Nettoyer
rm /tmp/inventory.sql
```
### Structure finale du dossier
```
/var/www/inventory/
├── docker-compose.yml
├── deploy.sh
├── .env
└── uploads/
```
---
## Deployer une nouvelle version
Quand l'app est deja installee, deployer une mise a jour :
```bash
cd /var/www/inventory
./deploy.sh # deploie la derniere version (latest)
./deploy.sh v1.9.4 # deploie une version specifique
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
---
## Rollback
### Image seule (pas de changement de schema BDD)
```bash
./deploy.sh v1.9.3
```
### Avec rollback de migration
```bash
# 1. Rollback schema (pendant que la version actuelle tourne encore)
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
# 2. Deployer l'ancienne version
./deploy.sh v1.9.3
```
---
## CI/CD
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
1. Build l'image multi-stage (inclut checkout des submodules pour le frontend)
2. Push vers `gitea.malio.fr/malio-dev/inventory:<tag>` et `:latest`
---
## Voir les logs
```bash
cd /var/www/inventory
docker compose logs -f # tous les logs
docker compose logs -f --tail=100 # 100 dernieres lignes
```
Logs Symfony :
```bash
docker compose exec app cat var/log/prod.log
```
---
## Migration depuis l'ancien deploiement (bare-metal)
Si l'application tourne deja en bare metal :
1. Installer Docker (voir pre-requis)
2. Creer le dossier `/var/www/inventory-docker/` (ne pas ecraser l'ancien)
3. Copier les fichiers existants :
```bash
cp /var/www/inventory/.env /var/www/inventory-docker/.env
cp -a /var/www/inventory/var/uploads /var/www/inventory-docker/uploads
```
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/inventory-docker/` (voir etape 4 ci-dessus)
5. Editer `/var/www/inventory-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 7 ci-dessus)
8. Arreter l'ancien PHP-FPM/Apache : `sudo systemctl stop php8.4-fpm` ou `sudo systemctl stop apache2`
9. Deployer : `cd /var/www/inventory-docker && ./deploy.sh`
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/inventory-docker /var/www/inventory`

View File

@@ -0,0 +1,278 @@
# Champs Personnalises - Diagnostic Et Recuperation
Date : 2026-03-23
---
## Contexte
Un bug sur la sauvegarde des categories (`ModelType`) pouvait recreer des definitions de champs personnalises avec de nouveaux IDs.
Effet de bord :
- les `CustomFieldValue` existants restaient lies aux anciens `CustomField`
- puis etaient supprimes en cascade
- resultat visible : apres modification d'une categorie, certaines valeurs de champs perso disparaissaient
Le correctif preventif a ete fait :
- conservation des `id/customFieldId` cote frontend pour `PIECE/PRODUCT`
- matching backend plus robuste sur `id`, puis `orderIndex`, puis nom
Ce document couvre uniquement :
- comment detecter ce qui manque
- comment lire le listing
- comment identifier ce qui est recuperable depuis l'audit
- comment restaurer proprement
---
## Commandes Disponibles
### 1. Lister tous les champs perso manquants ou vides
Dans le conteneur :
```bash
php bin/console app:check-missing-custom-field-values
```
Variantes utiles :
```bash
php bin/console app:check-missing-custom-field-values --entity=piece
php bin/console app:check-missing-custom-field-values --entity=composant
php bin/console app:check-missing-custom-field-values --max-rows=1000
php bin/console app:check-missing-custom-field-values --limit=500 --max-rows=1000
```
### 2. Afficher uniquement les cas recuperables depuis l'audit
```bash
php bin/console app:check-missing-custom-field-values --recoverable-only
```
Variantes :
```bash
php bin/console app:check-missing-custom-field-values --entity=piece --recoverable-only
php bin/console app:check-missing-custom-field-values --entity=composant --recoverable-only
php bin/console app:check-missing-custom-field-values --recoverable-only --max-rows=1000
```
### 3. Dry-run de restauration pour une piece
```bash
php bin/console app:restore-piece-custom-field-values <pieceId>
```
Exemple :
```bash
php bin/console app:restore-piece-custom-field-values cl731386df55fcb9e6a01e0a63
```
### 4. Appliquer la restauration pour une piece
```bash
php bin/console app:restore-piece-custom-field-values <pieceId> --apply
```
---
## Colonnes Du Listing
La commande `app:check-missing-custom-field-values` affiche :
- `Entity` : `piece` ou `composant`
- `ID` : identifiant de l'entite
- `Name` : nom de l'entite
- `Reference` : reference metier si presente
- `Category` : nom de la categorie (`ModelType`)
- `Field` : nom du champ personnalise attendu par la categorie
- `Issue` : `missing` ou `empty`
- `Recoverable` : `yes` ou `no`
- `Audit value` : derniere valeur non vide retrouvee dans l'audit si disponible
---
## Signification Des Statuts
### `missing`
Il n'existe actuellement **aucune** ligne `CustomFieldValue` pour ce champ sur l'entite.
Cela peut vouloir dire :
- la valeur n'a jamais ete saisie
- la valeur a ete perdue lors du bug
- le champ a ete ajoute plus tard sur la categorie sans initialisation des anciennes entites
### `empty`
La ligne `CustomFieldValue` existe, mais sa valeur est vide.
Cela est plus suspect qu'un `missing`, mais ne prouve pas a lui seul une perte.
### `Recoverable = yes`
L'audit contient au moins une ancienne valeur non vide pour ce champ.
En pratique :
- c'est le signal le plus utile
- ce sont les cas a traiter en priorite
- ces cas sont potentiellement restaurables automatiquement
### `Recoverable = no`
L'audit de cette entite ne contient pas de valeur non vide exploitable pour ce champ.
Cela ne veut **pas** forcement dire qu'il n'y a jamais eu de valeur.
Cela veut simplement dire :
- rien de recuperable n'a ete trouve dans les logs d'audit consultes
---
## Lecture Des Cas Typiques
### Cas 1
```text
piece ... Roulement ... Diametre ... missing ... no
```
Interpretation :
- le champ `Diametre` est attendu sur cette piece
- aucune valeur n'existe actuellement
- l'audit ne permet pas de retrouver une ancienne valeur
Conclusion :
- non recuperable automatiquement
- a verifier metierement si la valeur a deja existe ou non
### Cas 2
```text
piece ... Arbre ... Diametre ... empty ... yes ... 35 mm
```
Interpretation :
- une ligne de valeur existe mais elle est vide
- l'audit montre qu'une ancienne valeur `35 mm` existait
Conclusion :
- cas typique de restauration automatique possible
### Cas 3
```text
piece ... Joint ... Matiere ... missing ... yes ... NBR
```
Interpretation :
- la valeur n'existe plus du tout
- l'audit permet de retrouver `NBR`
Conclusion :
- forte probabilite de perte historique
- recuperable automatiquement
---
## Priorisation Recommandee
Ordre de traitement conseille :
1. `empty + yes`
2. `missing + yes`
3. `empty + no`
4. `missing + no`
Pourquoi :
- les `yes` sont les seuls cas recuperables automatiquement
- les `empty` indiquent souvent une valeur ecrasee
- les `missing no` sont nombreux mais souvent ambigus
---
## Procedure Recommandee
### Etape 1 - Scanner globalement
```bash
php bin/console app:check-missing-custom-field-values --recoverable-only --max-rows=1000
```
### Etape 2 - Identifier les pieces prioritaires
Chercher :
- les pieces les plus critiques metierement
- les categories fortement touchees (`Roulement`, `Joint`, `Arbre`, etc.)
- les cas avec valeur d'audit explicite
### Etape 3 - Faire un dry-run piece par piece
```bash
php bin/console app:restore-piece-custom-field-values <pieceId>
```
### Etape 4 - Appliquer uniquement apres verification
```bash
php bin/console app:restore-piece-custom-field-values <pieceId> --apply
```
---
## Limites Actuelles
### Ce qui est pris en charge
- diagnostic global sur les `pieces`
- diagnostic global sur les `composants`
- restauration automatique ciblee sur les `pieces`
### Ce qui n'est pas encore automatise
- restauration automatique en masse
- restauration automatique des `composants`
- reconstitution si l'audit ne contient aucune ancienne valeur exploitable
---
## Interpretation Metier
Le listing global ne doit pas etre lu comme :
> "866 valeurs ont ete perdues"
Il doit etre lu comme :
> "866 couples entite/champ sont actuellement manquants ou vides par rapport aux definitions de categories"
Parmi eux :
- certains n'ont jamais ete renseignes
- certains ont probablement ete perdus
- seuls les cas `Recoverable = yes` sont candidates a une recuperation automatique fiable
---
## Commandes Resumees
```bash
# Tout lister
php bin/console app:check-missing-custom-field-values
# Afficher uniquement les cas recuperables
php bin/console app:check-missing-custom-field-values --recoverable-only
# Scanner seulement les pieces
php bin/console app:check-missing-custom-field-values --entity=piece --recoverable-only
# Scanner seulement les composants
php bin/console app:check-missing-custom-field-values --entity=composant --recoverable-only
# Dry-run de restauration d'une piece
php bin/console app:restore-piece-custom-field-values <pieceId>
# Application reelle
php bin/console app:restore-piece-custom-field-values <pieceId> --apply
```

View File

@@ -0,0 +1,144 @@
# Resultats Recuperables - Champs Personnalises
Date : 2026-03-23
Source : `php bin/console app:check-missing-custom-field-values --recoverable-only`
---
## Resume
- Total : 40 cas recuperables
- Pieces : 40
- Composants : 0
- Type de probleme observe : uniquement `empty`
- Categorie dominante : `Arbre`
- Champ le plus frequent : `Diamètre`
Conclusion :
- il n'y a pas ici une grande dispersion de cas heterogenes
- la quasi-totalite du lot correspond a des valeurs historisees recuperables sur des pieces de categorie `Arbre`
- ces cas sont de bons candidats a une restauration automatique
---
## Tableau
| Entity | ID | Name | Reference | Category | Field | Issue | Recoverable | Audit value |
|---|---|---|---|---|---|---|---|---|
| piece | `clc08fbdcd334ed869772d98ee` | Arbre de la cage écureuil pied E4 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `cl8570d729efd017c12a2d5c3d` | Arbre du tambour tête E7 | | Arbre | Diamètre | empty | yes | 40 mm |
| piece | `cle1db7051dbef91fc009073a6` | Arbre de la cage écureuil pied E6 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `cl9282d473ff01b5d1df8bc945` | Arbre E1 | | Arbre | Diamètre | empty | yes | 50 |
| piece | `cl22e81a055f9c393d8d2c82fc` | Arbre du palier pied E3 | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `clca9379d4aa76de6772ebbe1a` | Arbre pignon | `0-5720-00` | Arbre | Type | empty | yes | 20 DTS |
| piece | `clc97804ec0bf8b6d9bb530717` | Arbre du palier tête E2 E2B | | Arbre | Diamètre | empty | yes | 40 |
| piece | `cl1597f1500c1052e9e7a95c51` | Arbre du palier pied E2 E2B | | Arbre | Diamètre | empty | yes | 35 mm |
| piece | `cleea7ff4b9b1a6396a0bb9ea8` | Arbre du tambour tête E1 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cl5c71e3777146de5508e07156` | Arbre de la cage écureuil pied E1 | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `cl731386df55fcb9e6a01e0a63` | Arbre de la cage écureuil pied E2 E2B | | Arbre | Diamètre | empty | yes | 35 mm |
| piece | `clfaf128312d5c253d928f47ac` | Arbre du palier pied E4 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `clbf9f0070ebd464b3c309c646` | Arbre du palier pied E8 | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `clc7c00cad416477d4438cd61a` | Arbre du tambour tête E8 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cl3f01a1a514423359405a4825` | Arbre du palier tête E7 | | Arbre | Diamètre | empty | yes | 40 mm |
| piece | `clf16e543545eddd01b20077df` | Arbre du tambour tête E5 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `clb6c61ebb8da2c4361265f766` | Arbre du palier tête E6 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `cl8da1b875191c617e5852bf81` | Arbre du tambour tête E2 E2B | | Arbre | Diamètre | empty | yes | 40 mm |
| piece | `cl8da1b875191c617e5852bf81` | Arbre du tambour tête E2 E2B | | Arbre | Diamètre palier | empty | yes | 40 |
| piece | `cla82d44c52d7eb2a592f4120d` | Arbre du palier pied E7 | | Arbre | Diamètre | empty | yes | 35 mm |
| piece | `clf8562d27a542f86f8f4a5629` | Arbre du palier tête E8 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `clde7ee756c2cf264c062b861d` | Arbre du palier pied E6 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `cl6667d159f6d07ba77fa79b39` | Arbre de la cage écureuil pied E5 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `cl455ad597bcee2a8e3c099420` | Arbre du palier pied E5 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `cl22c13dbc4d38a1f846323ae6` | Arbre de la cage écureuil pied E3 | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `cl1406ef19de58fdd1adf40221` | Arbre de la cage écureuil pied E7 | | Arbre | Diamètre | empty | yes | 35 mm |
| piece | `clafaa71cbf49777fbb8415f19` | Arbre du tambour tête E3 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cle255aea44755dbbe7e466a99` | Arbre du palier tête E5 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `cl3d978dd4b071daff8fb185f7` | Arbre du palier pied E1 | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `cl5e8aba1867089544d71fe2c5` | Arbre du palier tête E4 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `cl04c79cd568894a5674b46a31` | Arbre du palier pied élévateur expédition | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `cl50fe870a07e42759b37b511f` | Arbre du tambour tête E6 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `cl531dde45c3fc64c1a3b16ca0` | Arbre de la cage écureuil pied élévateur expédition | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `cleca9e4baa9e9205f1dd948e1` | Arbre du palier tête E3 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cl5ee293dc7b61feba510082a4` | Arbre du tambour tête élévateur expédition | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cled68ff759b1f02f482990fb3` | Arbre du tambour du palier tête E11 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cmkr0qjw5004s1eq6pen63x7j` | Arbre du palier tête E1 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cl2c3570dd00372fed44cd5a43` | Arbre du palier tête élévateur expédition | `Décolleter a Ø40 pour réducteur` | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cl7b3702f04d24d87e47232a14` | Arbre du tambour tête E4 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `cldd656c6092225f53a22badc0` | Arbre de la cage écureuil pied E8 | | Arbre | Diamètre | empty | yes | 50 mm |
---
## Observations
### 1. Lot tres homogene
Le resultat est tres concentre :
- uniquement des `pieces`
- uniquement des cas `empty`
- presque uniquement sur le champ `Diamètre`
- presque toute la liste est dans la categorie `Arbre`
Cela ressemble davantage a une vague de perte coherente qu'a du bruit metier aleatoire.
### 2. Valeurs d'audit tres exploitables
Les valeurs retrouvees sont directement reutilisables :
- `35 mm`
- `40 mm`
- `45 mm`
- `50 mm`
- `55 mm`
- `70 mm`
- `20 DTS`
### 3. Cas particulier multi-champs
L'entite `cl8da1b875191c617e5852bf81` a deux champs recuperables :
- `Diamètre`
- `Diamètre palier`
### 4. Piece initialement signalee
La piece `cl731386df55fcb9e6a01e0a63` est bien presente dans le resultat :
- nom : `Arbre de la cage écureuil pied E2 E2B`
- champ : `Diamètre`
- valeur recuperable : `35 mm`
---
## Priorite De Restauration
Priorite haute :
- restaurer tout ce lot `Arbre` en premier
- ce sont des cas homogènes et recuperables
Ordre recommande :
1. piece `cl731386df55fcb9e6a01e0a63`
2. piece avec plusieurs champs recuperables : `cl8da1b875191c617e5852bf81`
3. reste du lot `Arbre`
---
## Commandes Utiles
Dry-run pour une piece :
```bash
php bin/console app:restore-piece-custom-field-values <pieceId>
```
Application reelle :
```bash
php bin/console app:restore-piece-custom-field-values <pieceId> --apply
```
Exemple pour la piece initiale :
```bash
php bin/console app:restore-piece-custom-field-values cl731386df55fcb9e6a01e0a63
php bin/console app:restore-piece-custom-field-values cl731386df55fcb9e6a01e0a63 --apply
```

View File

@@ -0,0 +1,137 @@
# Doublons de références — Composants
> Généré le 2026-03-26 à partir du dump de production `inventory (17).sql.gz`
**13 références en doublon** pour un total de **41 composants concernés**.
## Résumé
| Référence | Nb | Composants |
|---|---|---|
| Tambour lisse | 9 | Tambour tête E1, E2 E2B, E3, E4, E5, E6, E7, E8, élévateur expédition |
| FY50 FM | 5 | Opposé commande Vis 21, Palier Opposé Commande Vis 19, Palier Vis 18 (côté commande), Palier Vis 21 (côté commande), Palier côté commande Vis 20 |
| PB 2220 | 4 | Réducteur pendulaire E1, E3, E8, élévateur expédition |
| SNU 511 609 | 4 | Palier pied E1, E3, E8, élévateur expédition |
| SNU 516 613 | 4 | Palier tête E1, E3, E8, élévateur expédition |
| 512610 SNH SKF | 3 | Palier tête E4, E5, E6 |
| FY 50 FM | 2 | Palier V18 (opposé commande), Palier côté commande Vis 19 |
| FY60 | 2 | Palier Vis 17 (coté commande), Palier Vis 17 (opposé commande) |
| FY60 WF | 2 | Palier Opposé commande Vis 22, Palier côté commande Vis 22 |
| PB 2012 | 2 | Réducteur pendulaire E2-E2B, E7 |
| PB 2112 | 2 | Réducteur pendulaire E4, E6 |
| SNU 509 | 2 | Palier tête E2 et E2B, E7 |
| VCF 207 | 2 | Palier pied E2 et E2B, E7 |
## Détail par référence
### Tambour lisse (9 composants)
| Nom | ID |
|---|---|
| Tambour tête E1 | cl4660bae41d2af254e6c3b726 |
| Tambour tête E2 E2B | cl5e9c6b18bccd38517026dc1c |
| Tambour tête E3 | clba5633e840726188261145f9 |
| Tambour tête E4 | cl10c0924d10135c5f515378ac |
| Tambour tête E5 | cl7f254c23161d9c853c3e6d92 |
| Tambour tête E6 | cl3dbac5194bc192a0589465ba |
| Tambour tête E7 | cla833681664bb851ca61aca51 |
| Tambour tête E8 | cl36d84884cad86fbc92dba133 |
| Tambour tête élévateur expédition | cl5a8f9656aa7e14c012f30700 |
### FY50 FM (5 composants)
| Nom | ID |
|---|---|
| Opposé commande Vis 21 | cl055eff4115f9c75d850c9459 |
| Palier Opposé Commande Vis 19 | cl6831a23892243bbaa2f823b4 |
| Palier Vis 18 (côté commande) | cld1391112241147dc064b35da |
| Palier Vis 21 (côté commande) | cl9f8253f4537a657f7378a2e9 |
| Palier côté commande Vis 20 | cl203937da81135d8b34d7bb0f |
### PB 2220 (4 composants)
| Nom | ID |
|---|---|
| Réducteur pendulaire E1 | cla59f867feafbb0937862064a |
| Réducteur pendulaire E3 | cl33683086c4de13f80db59606 |
| Réducteur pendulaire E8 | cl94fb77cf922aa1462a8d13cc |
| Réducteur pendulaire élévateur expédition | cl3f02941228dfef4c91a75d1a |
### SNU 511 609 (4 composants)
| Nom | ID |
|---|---|
| Palier pied E1 | cl81e703e9f200163a4ea473df |
| Palier pied E3 | cl3d38928c11d70614bb09fe8e |
| Palier pied E8 | cl78b79a8f90f12842b5683403 |
| Palier pied élévateur expédition | clf35b4455617ae94f2a1add46 |
### SNU 516 613 (4 composants)
| Nom | ID |
|---|---|
| Palier tête E1 | cmkr0nq1a004e1eq6v6ubxlfl |
| Palier tête E3 | cl92b8908c71616c542d958007 |
| Palier tête E8 | clce6dde0609d90764da383d75 |
| Palier tête élévateur expédition | clb7322b05f9a4554fa5a75d5a |
### 512610 SNH SKF (3 composants)
| Nom | ID |
|---|---|
| Palier tête E4 | cl8e90ad1b633046f5f1344b93 |
| Palier tête E5 | clbbe4096490ff89b08644c793 |
| Palier tête E6 | cl51c9a1c3dce52856e3404a38 |
### FY 50 FM (2 composants)
| Nom | ID |
|---|---|
| Palier V18 (opposé commande) | cl2ff55d9fa9c52c18f2d88222 |
| Palier côté commande Vis 19 | clbddd1dca5efa881b23eaa1cd |
### FY60 (2 composants)
| Nom | ID |
|---|---|
| Palier Vis 17 (coté commande) | cl02b0a0a543cc699681b6ae8c |
| Palier Vis 17 (opposé commande) | clc0ba9245b63613307cc26a19 |
### FY60 WF (2 composants)
| Nom | ID |
|---|---|
| Palier Opposé commande Vis 22 | cl318b49462097fb2e1f793305 |
| Palier côté commande Vis 22 | cl6bc818a2d8661b5e0ce2d0c0 |
### PB 2012 (2 composants)
| Nom | ID |
|---|---|
| Réducteur pendulaire E2-E2B | cl9b746a66f583fc85b3d176c4 |
| Réducteur pendulaire E7 | clc0db3b431d75c6355608efd5 |
### PB 2112 (2 composants)
| Nom | ID |
|---|---|
| Réducteur pendulaire E4 | clf5a1c9e1f8202b632f173bd3 |
| Réducteur pendulaire E6 | cle1899c6522cb8b8abd366a24 |
### SNU 509 (2 composants)
| Nom | ID |
|---|---|
| Palier tête E2 et E2B | cl4e600dcadb34f817a888ffa3 |
| Palier tête E7 | cl84271e9ab5351cbd188b0d3a |
### VCF 207 (2 composants)
| Nom | ID |
|---|---|
| Palier pied E2 et E2B | cld516a118bb1c478722a1d39b |
| Palier pied E7 | cl908dbf171798f087b12d6f2a |
## Note
Ces doublons sont des composants **distincts** (noms différents, installés sur différents élévateurs) qui partagent la même référence fournisseur. Il ne s'agit pas nécessairement d'entrées à fusionner, mais de pièces identiques utilisées à plusieurs emplacements.

399
docs/FONCTIONNEMENT.md Normal file
View File

@@ -0,0 +1,399 @@
# Fonctionnement de l'application Inventory
## 1. A quoi sert cette application ?
Inventory est une application de **gestion d'inventaire industriel**. Elle permet de suivre et documenter l'ensemble du parc de machines d'une entreprise, avec tous les elements qui les composent : composants, pieces detachees et produits consommables.
L'objectif principal est d'avoir une **vue complete et structuree** de chaque machine : quels composants elle contient, quelles pieces sont montees dessus, quels produits sont utilises, qui les fabrique, combien ils coutent, et toute la documentation associee (manuels, fiches techniques, etc.).
---
## 2. Les entites principales
L'application s'articule autour de 7 entites fondamentales :
```
+-----------------------------------------------------------+
| SITE |
| (usine, atelier, entrepot...) |
| - nom, adresse, contact, telephone, ville, code postal |
| - couleur (pour identification visuelle) |
+-----------------------------------------------------------+
|
| contient des
v
+-----------------------------------------------------------+
| MACHINE |
| (machine industrielle sur un site) |
| - nom (unique), reference, prix |
| - rattachee a 1 site obligatoirement |
| - peut avoir plusieurs fournisseurs/constructeurs |
+-----------------------------------------------------------+
|
| est composee de
v
+-------------------+ +-------------------+ +-------------------+
| COMPOSANT | | PIECE | | PRODUIT |
| (element fonct.) | | (piece detachee) | | (consommable) |
| - nom, ref, desc | | - nom, ref, desc | | - nom, ref |
| - prix | | - prix | | - prix fournisseur |
| - categorie | | - categorie | | - categorie |
| - fournisseurs | | - fournisseurs | | - fournisseurs |
+-------------------+ +-------------------+ +-------------------+
```
### Site
Un **site** represente un lieu physique : une usine, un atelier, un entrepot. Chaque site possede un nom, une adresse complete et un contact. Toutes les machines sont obligatoirement rattachees a un site.
### Machine
Une **machine** est l'entite centrale. C'est un equipement industriel installe sur un site. Chaque machine a un nom unique, une reference optionnelle et un prix. Elle contient une structure hierarchique de composants, pieces et produits.
### Composant
Un **composant** represente un element fonctionnel d'une machine (ex : un moteur, un systeme hydraulique, un automate). Un composant peut lui-meme contenir des sous-composants, des pieces et des produits, formant une structure arborescente.
### Piece
Une **piece** est une piece detachee (ex : un roulement, un joint, un filtre). Les pieces peuvent etre rattachees directement a une machine ou a un composant au sein d'une machine.
### Produit
Un **produit** est un consommable ou article fournisseur (ex : huile, lubrifiant, boulon specifique). Comme les pieces, les produits peuvent etre associes a une machine, a un composant ou a une piece.
### Constructeur (Fournisseur)
Un **constructeur** est un fabricant ou fournisseur. C'est un referentiel partage : le meme fournisseur peut etre associe a des machines, des composants, des pieces et des produits. Chaque fournisseur a un nom, un email et un telephone.
### Categorie (ModelType)
Une **categorie** (appelee ModelType dans le systeme) permet de classifier les composants, les pieces et les produits. Le systeme de categories est explique en detail dans la section suivante.
---
## 3. Le systeme de categories (ModelType)
Les categories sont un element cle de l'application. Elles servent a **classifier ET a structurer** les elements de l'inventaire.
### Trois familles de categories
Il existe trois familles de categories, une par type d'element :
| Famille | S'applique aux | Exemples |
|-------------|----------------|-----------------------------------------|
| COMPONENT | Composants | "Moteur electrique", "Systeme hydraulique" |
| PIECE | Pieces | "Roulement", "Joint torique", "Filtre" |
| PRODUCT | Produits | "Huile moteur", "Graisse", "Boulon M8" |
### Le squelette (skeleton) : la structure imposee
La vraie puissance des categories de composants reside dans leur **squelette**. Quand on cree une categorie de composant, on definit un modele qui impose :
- **Quelles pieces** sont necessaires (par type de piece)
- **Quels produits** sont necessaires (par type de produit)
- **Quels sous-composants** sont necessaires (par type de composant)
- **Quels champs personnalises** doivent etre remplis
**Exemple concret :** La categorie "Moteur electrique" pourrait imposer :
- 1 piece de type "Roulement"
- 1 piece de type "Joint"
- 1 produit de type "Huile moteur"
- 1 sous-composant de type "Variateur"
- Des champs personnalises : "Puissance (kW)", "Vitesse (tr/min)", "Tension (V)"
```
Categorie "Moteur electrique" (squelette)
|
|-- Piece requise : type "Roulement" --> l'utilisateur choisira quel roulement precis
|-- Piece requise : type "Joint" --> l'utilisateur choisira quel joint precis
|-- Produit requis : type "Huile moteur" --> l'utilisateur choisira quelle huile precise
|-- Sous-composant : type "Variateur" --> l'utilisateur choisira quel variateur precis
|-- Champ personnalise : "Puissance (kW)" --> l'utilisateur saisira la valeur
|-- Champ personnalise : "Tension (V)" --> l'utilisateur saisira la valeur
```
Les categories de pieces peuvent elles aussi definir des produits requis et des champs personnalises. Les categories de produits peuvent definir des champs personnalises.
### Champs personnalises
Les champs personnalises permettent d'ajouter des informations specifiques selon la categorie. Chaque champ a :
- Un **nom** (ex : "Puissance")
- Un **type** (texte, nombre, date, etc.)
- Un caractere **obligatoire ou non**
- Des **options** possibles (pour les listes deroulantes)
- Une **valeur par defaut**
- Un **ordre d'affichage**
Les machines disposent aussi de champs personnalises, mais ceux-ci sont definis directement sur chaque machine (et non via une categorie).
---
## 4. Le cycle de vie d'un composant
Voici les etapes typiques de creation et utilisation d'un composant :
```
1. CREATION 2. SELECTION CATEGORIE 3. REMPLISSAGE SQUELETTE
+-------------------+ +------------------------+ +---------------------------+
| Saisir : | | Choisir la categorie : | | Le squelette apparait : |
| - Nom | ----> | "Moteur electrique" | --> | - Piece "Roulement" : [?] |
| - Reference | | | | - Piece "Joint" : [?] |
| - Description | | Le systeme charge le | | - Produit "Huile" : [?] |
| - Prix | | squelette associe | | |
| - Fournisseurs | +------------------------+ | Choisir dans le catalogue |
+-------------------+ | chaque element concret |
+---------------------------+
|
5. DOCUMENTS 4. CHAMPS PERSONNALISES
+---------------------+ +-----------------------------+
| Joindre des fichiers | <---- | Remplir les champs definis |
| - Manuels PDF | | par la categorie : |
| - Fiches techniques | | - Puissance : 15 kW |
| - Photos | | - Tension : 400 V |
| - Schemas | | - Vitesse : 1500 tr/min |
+---------------------+ +-----------------------------+
```
**Etape 1 - Creation :** L'utilisateur saisit les informations de base du composant (nom, reference, description, prix) et selectionne un ou plusieurs fournisseurs.
**Etape 2 - Selection de la categorie :** L'utilisateur choisit la categorie du composant (ex : "Moteur electrique"). Le systeme charge alors le squelette defini pour cette categorie.
**Etape 3 - Remplissage du squelette :** Des "emplacements" (slots) apparaissent pour chaque element requis par le squelette. L'utilisateur selectionne dans le catalogue existant les pieces, produits et sous-composants concrets qui correspondent a chaque emplacement.
**Etape 4 - Champs personnalises :** L'utilisateur remplit les champs personnalises definis par la categorie (puissance, tension, etc.).
**Etape 5 - Documents :** L'utilisateur peut joindre des fichiers au composant : manuels PDF, fiches techniques, photos, schemas...
Ce meme principe s'applique aux pieces (qui peuvent avoir des produits associes et des champs personnalises definis par leur categorie) et aux produits (qui peuvent avoir des champs personnalises).
---
## 5. Les roles utilisateurs
L'application utilise 4 niveaux de droits, organises en hierarchie. Chaque role herite automatiquement des droits du role inferieur :
```
+------------------------------------------------------------------+
| ROLE_ADMIN |
| Tout faire + gerer les utilisateurs (creer, modifier, supprimer |
| des comptes, attribuer des roles) |
+------------------------------------------------------------------+
| herite de
v
+------------------------------------------------------------------+
| ROLE_GESTIONNAIRE |
| Creer, modifier et supprimer les machines, composants, pieces, |
| produits, sites, fournisseurs, categories, documents, |
| commentaires. C'est le role d'edition principal. |
+------------------------------------------------------------------+
| herite de
v
+------------------------------------------------------------------+
| ROLE_VIEWER |
| Consulter tout l'inventaire en lecture seule : naviguer dans |
| les machines, voir les structures, les catalogues, l'historique |
| et les documents. |
+------------------------------------------------------------------+
| herite de
v
+------------------------------------------------------------------+
| ROLE_USER |
| Role de base attribue automatiquement a tout utilisateur |
| connecte. Acces minimal. |
+------------------------------------------------------------------+
```
En resume :
- **Admin** : fait tout, y compris gerer les comptes utilisateurs
- **Gestionnaire** : cree et modifie les donnees de l'inventaire
- **Viewer** : consulte l'inventaire sans pouvoir le modifier
- **User** : role de base, acces minimal
---
## 6. Les fonctionnalites cles
### Catalogues
L'application propose des **catalogues** pour chaque type d'element :
- **Catalogue des composants** : liste tous les composants avec recherche par nom, reference ou categorie
- **Catalogue des pieces** : liste toutes les pieces detachees
- **Catalogue des produits** : liste tous les produits fournisseurs
- **Liste des machines** : toutes les machines, organisees par site
- **Liste des sites** : tous les sites industriels
- **Liste des fournisseurs** : tous les constructeurs/fournisseurs
Chaque catalogue offre des fonctions de **recherche**, de **tri** et de **pagination**.
### Recherche
La recherche est disponible dans tous les catalogues et permet de filtrer par :
- Nom (recherche partielle, insensible a la casse)
- Reference (recherche partielle)
- Categorie (filtre exact ou par nom)
### Historique et audit
Chaque modification dans l'application est **tracee automatiquement**. Le systeme enregistre :
- **Qui** a fait la modification (quel utilisateur)
- **Quand** la modification a ete faite
- **Quoi** a ete modifie (les champs avant/apres)
- **Sur quel element** (machine, composant, piece, produit...)
On peut consulter :
- L'**historique d'une entite** : toutes les modifications apportees a une machine, un composant, etc.
- Le **journal d'activite global** : toutes les modifications recentes dans l'application
### Commentaires
Les utilisateurs peuvent **commenter** n'importe quel element de l'inventaire (machines, composants, pieces, produits, categories). Les commentaires ont un systeme de **resolution** : un commentaire ouvert peut etre marque comme "resolu" par un gestionnaire. Un compteur de commentaires non resolus est disponible.
### Documents
Des fichiers peuvent etre joints a toutes les entites principales :
- **Sites** : plans, reglements
- **Machines** : manuels, fiches techniques
- **Composants** : documentations constructeur
- **Pieces** : plans de pieces, specifications
- **Produits** : fiches de donnees de securite, catalogues
Les fichiers sont uploades via l'interface et peuvent etre consultes ou telecharges a tout moment. L'application gere differents formats : PDF, images, etc.
### Clonage de machines
Quand une nouvelle machine est identique ou similaire a une existante, il est possible de **cloner une machine**. Le clonage copie :
- Les champs personnalises et leurs valeurs
- Toute la structure : les liens vers les composants, pieces et produits
- La hierarchie (quel composant contient quelles pieces, etc.)
L'utilisateur choisit un nouveau nom et un site de destination. La machine clonee peut ensuite etre modifiee independamment de l'originale.
---
## 7. La structure des machines
### Vue d'ensemble
Chaque machine possede une **structure hierarchique** qui decrit de quoi elle est composee. Cette structure est une arborescence :
```
Machine "Presse hydraulique PH-200"
|
|-- Composant "Moteur principal M1"
| |-- Piece "Roulement SKF 6205" (quantite: 2)
| | |-- Produit "Graisse SKF LGMT2"
| |-- Piece "Joint Viton DN50"
| |-- Produit "Huile Total Azolla ZS 46"
| |-- Sous-composant "Variateur ABB ACS580"
| |-- Piece "Fusible 63A"
| |-- Produit "Pate thermique"
|
|-- Composant "Groupe hydraulique GH-01"
| |-- Piece "Filtre Parker 926169Q"
| |-- Piece "Verin Bosch CDT3" (quantite: 4)
| |-- Produit "Huile hydraulique HLP 46"
|
|-- Piece "Courroie Gates PowerGrip" (piece directement sur la machine)
|-- Produit "Boulon M12x50 Inox" (produit directement sur la machine)
```
### Les liens (links)
Les elements ne sont pas directement "dans" la machine. Ils y sont rattaches par des **liens** :
- **MachineComponentLink** : rattache un composant a une machine
- **MachinePieceLink** : rattache une piece a une machine
- **MachineProductLink** : rattache un produit a une machine
Ces liens permettent :
- De definir la **hierarchie** : un composant peut etre parent d'une piece ou d'un produit, un sous-composant peut etre enfant d'un autre composant
- De specifier une **quantite** (ex : 4 verins identiques)
- De faire des **surcharges** : modifier le nom, la reference ou le prix d'un element specifiquement dans le contexte de cette machine, sans modifier l'element du catalogue
### Hierarchie parent-enfant
```
MachineComponentLink (composant dans la machine)
|
|-- parentLink --> null (composant racine, directement dans la machine)
| ou
|-- parentLink --> autre MachineComponentLink (sous-composant)
|
|-- pieceLinks --> MachinePieceLink[] (pieces de ce composant)
|-- productLinks --> MachineProductLink[] (produits de ce composant)
MachinePieceLink (piece dans la machine)
|
|-- parentLink --> MachineComponentLink (piece rattachee a un composant)
| ou
|-- parentLink --> null (piece directement sur la machine)
|
|-- productLinks --> MachineProductLink[] (produits de cette piece)
```
### Catalogue vs. Structure machine
Un point important : les **composants, pieces et produits existent dans un catalogue global**. Quand on les ajoute a une machine, on cree un lien vers l'element du catalogue. Le meme composant du catalogue peut donc etre utilise dans plusieurs machines.
Les surcharges (nom, reference, prix) permettent d'adapter les informations au contexte d'une machine specifique sans modifier la fiche catalogue.
```
CATALOGUE (reference globale) MACHINE (utilisation specifique)
+-------------------------+ +--------------------------------+
| Composant "Moteur 15kW" | | Lien vers "Moteur 15kW" |
| Ref: MOT-15-01 | <-------- | Surcharge nom: "Moteur gauche" |
| Prix: 2500 EUR | | Surcharge prix: 2200 EUR |
+-------------------------+ +--------------------------------+
```
---
## Schemas recapitulatifs
### Relations entre entites
```
+--------+
| Site |
+--------+
|
contient (1..N)
|
+-----------+
| Machine |------------ Fournisseurs (N..N)
+-----------+
/ | \
/ | \
Composants Pieces Produits
(via liens) (via liens) (via liens)
+-----------+ +--------+ +---------+
| Composant | | Piece | | Produit |
+-----------+ +--------+ +---------+
| | |
|-- Categorie |-- Categorie |-- Categorie
|-- Fournisseurs -- Fournisseurs -- Fournisseurs
|-- Documents |-- Documents |-- Documents
|-- Champs perso -- Champs perso -- Champs perso
|
|-- Sous-composants (arborescence)
|-- Pieces (slots depuis le squelette)
|-- Produits (slots depuis le squelette)
```
### Flux de creation typique
```
1. Creer les SITES
|
2. Creer les CATEGORIES (avec leurs squelettes)
|
3. Creer les FOURNISSEURS
|
4. Creer les PRODUITS (en les categorisant)
|
5. Creer les PIECES (en les categorisant, en leur associant des produits)
|
6. Creer les COMPOSANTS (en choisissant une categorie,
| en remplissant le squelette avec des pieces/produits/sous-composants)
|
7. Creer les MACHINES (sur un site)
|
8. STRUCTURER les machines (ajouter composants, pieces, produits)
|
9. DOCUMENTER (joindre des fichiers a chaque element)
```

146
docs/GLOSSAIRE_METIER.md Normal file
View File

@@ -0,0 +1,146 @@
# Glossaire Métier — Inventory
## Contexte
**Inventory** est une application de gestion de parc machines industriel. Elle permet aux équipes de maintenance de cataloguer leurs machines, leurs sous-ensembles (composants), les pièces de rechange et les consommables associés. Chaque machine est rattachée à un site physique (usine, atelier). L'application gère la hiérarchie complète : Machine → Composants → Pièces/Produits, avec traçabilité (audit), documentation technique et champs personnalisables.
---
## Concepts Métier
### Hiérarchie principale
| Terme | Définition | Exemples concrets |
|-------|-----------|-------------------|
| **Site** | Lieu physique (usine, atelier, entrepôt). Regroupe les machines d'un même emplacement. | Usine de Lyon, Atelier Nord |
| **Machine** | Équipement industriel installé sur un site. C'est l'unité de base du parc. Contient des composants, pièces et produits. | Presse hydraulique, Tour CNC, Ligne d'embouteillage |
| **Composant** | Sous-ensemble fonctionnel d'une machine. Peut contenir des pièces, des produits, et d'autres sous-composants (imbrication). | Moteur, Pompe, Tableau électrique, Vérin |
| **Pièce** | Pièce mécanique/physique qu'on monte ou remplace. C'est l'unité de maintenance. | Joint, Écrou, Roulement, Capteur, Courroie |
| **Produit** | Consommable qu'on utilise sans monter. S'use et se renouvelle. | Huile, Dégraissant, Graisse, Liquide de refroidissement |
### Configuration et templates
| Terme | Définition |
|-------|-----------|
| **Modèle Type** (ModelType) | Template réutilisable qui définit la composition attendue d'un composant, d'une pièce ou d'un produit. Par exemple : "Pompe centrifuge XYZ nécessite 2 joints, 1 roulement et de l'huile hydraulique". |
| **Skeleton** (squelette) | La structure "vide" définie par un modèle type : la liste des emplacements requis (pièces, produits, sous-composants) avant qu'on y mette les éléments réels. |
| **Slot** (emplacement) | Emplacement concret dans un composant ou une pièce, créé à partir du skeleton. Chaque slot est à remplir avec une pièce, un produit ou un sous-composant réel. Un slot peut rester vide (pas encore sourcé). |
| **Sync** (synchronisation) | Propagation des modifications d'un modèle type vers tous les composants existants de ce type. Par exemple : ajouter un slot "filtre" au modèle type met à jour tous les composants de ce type. Surtout utilisé en phase de saisie initiale, quand on ajuste les modèles au fur et à mesure qu'on découvre la vraie composition des machines. |
| **Catégorie de modèle** | Un modèle type est classé en 3 catégories : Composant, Pièce ou Produit. Détermine quels skeletons il peut définir. |
### Transverse
| Terme | Définition |
|-------|-----------|
| **Constructeur** | Fournisseur ou fabricant. Peut être associé à une machine, un composant, une pièce ou un produit. Permet de tracer la chaîne d'approvisionnement. |
| **Champ personnalisé** (CustomField) | Attribut dynamique défini par l'utilisateur et attaché à une machine ou à un modèle type. Les composants/pièces/produits d'un même modèle type partagent les mêmes champs personnalisés. Exemples : "N° de série", "Date de garantie", "Intervalle de maintenance". |
| **Document** | Fichier attaché à n'importe quelle entité (machine, composant, pièce, produit, site, commentaire). Typé : Documentation, Devis, Facture, Plan, Photo, Autre. |
| **Commentaire** | Annotation utilisateur sur une entité, avec un statut ouvert ou résolu. Permet de signaler un problème, poser une question ou laisser une note. Peut contenir des pièces jointes. |
| **Journal d'audit** (AuditLog) | Historique automatique et immuable de toutes les créations, modifications et suppressions. Enregistre qui a fait quoi, quand, avec le détail des changements. |
### Utilisateurs et rôles
| Rôle | Droits |
|------|--------|
| **Admin** | Accès complet : gestion des utilisateurs, configuration, toutes les opérations |
| **Gestionnaire** | Créer, modifier, supprimer des machines/composants/pièces/produits |
| **Viewer** | Consultation seule, pas de modification |
| **User** | Rôle de base (accès minimal) |
---
## Workflows Utilisateur
### 1. Créer une machine
1. Choisir le **site** où la machine est installée
2. Renseigner nom, référence, prix, fournisseur(s)
3. Ajouter des **composants** à la machine (voir workflow 2)
4. Ajouter des **pièces** et **produits** directement sur la machine si nécessaire
5. Ajouter des **champs personnalisés** et des **documents**
### 2. Ajouter un composant à une machine
1. Choisir un **modèle type** pour le composant (ex: "Pompe centrifuge XYZ")
2. Les **slots** sont pré-créés automatiquement à partir du skeleton du modèle type
3. Remplir chaque slot en sélectionnant la pièce/produit/sous-composant réel
4. Les slots peuvent rester vides et être remplis plus tard
### 3. Créer ou modifier un modèle type
1. Nommer le modèle type et choisir sa catégorie (Composant, Pièce ou Produit)
2. Définir les emplacements requis : quelles pièces, quels produits, quels sous-composants
3. Définir les champs personnalisés (métadonnées) pour les entités de ce type
4. Si des composants existent déjà avec ce modèle type → utiliser le **sync** (workflow 4)
### 4. Synchroniser un modèle type
1. Modifier les emplacements du modèle type (ajout/suppression de slots)
2. Lancer un **sync preview** : visualiser l'impact sur les composants existants
3. Confirmer → les slots sont ajoutés/supprimés sur tous les composants du type
4. Surtout utile en phase de saisie initiale quand les données sont ajustées progressivement
### 5. Cloner une machine
1. Sélectionner une machine existante
2. Lancer le clonage → copie complète (composants, pièces, produits, liens, champs personnalisés)
3. Renommer la machine clonée et l'affecter à un site
### 6. Gérer les documents
1. Sélectionner une entité (machine, composant, pièce, produit, site)
2. Uploader un fichier (PDF, image, etc.)
3. Choisir le type : Documentation, Devis, Facture, Plan, Photo, Autre
4. Les documents sont consultables et téléchargeables depuis la fiche de l'entité
---
## Relations — Vue d'ensemble
```
Site
└── Machine
├── Composant (→ défini par un Modèle Type)
│ ├── Slot Pièce → Pièce (joint, écrou…)
│ ├── Slot Produit → Produit (huile, dégraissant…)
│ └── Slot Sous-composant → Composant (imbrication)
├── Pièce (directement sur la machine)
│ └── Slot Produit → Produit
└── Produit (directement sur la machine)
Modèle Type (template)
├── Skeleton Pièce Requirement → "il faut une pièce de type X"
├── Skeleton Produit Requirement → "il faut un produit de type Y"
└── Skeleton Sous-composant Requirement → "il faut un composant de type Z"
Transverse (attachable à toute entité) :
• Constructeur (fournisseur)
• Document (fichier)
• Commentaire (annotation)
• Champ personnalisé (métadonnée dynamique)
• Journal d'audit (historique automatique)
```
---
## Correspondance Métier ↔ Code
| Terme métier | Entité code | Table PG |
|-------------|-------------|----------|
| Site | `Site` | `site` |
| Machine | `Machine` | `machine` |
| Composant | `Composant` | `composant` |
| Pièce | `Piece` | `piece` |
| Produit | `Product` | `product` |
| Modèle Type | `ModelType` | `model_type` |
| Slot pièce (composant) | `ComposantPieceSlot` | `composant_piece_slot` |
| Slot produit (composant) | `ComposantProductSlot` | `composant_product_slot` |
| Slot sous-composant | `ComposantSubcomponentSlot` | `composant_subcomponent_slot` |
| Slot produit (pièce) | `PieceProductSlot` | `piece_product_slot` |
| Skeleton pièce | `SkeletonPieceRequirement` | `skeleton_piece_requirement` |
| Skeleton produit | `SkeletonProductRequirement` | `skeleton_product_requirement` |
| Skeleton sous-composant | `SkeletonSubcomponentRequirement` | `skeleton_subcomponent_requirement` |
| Constructeur | `Constructeur` | `constructeur` |
| Champ personnalisé | `CustomField` | `custom_field` |
| Valeur champ perso | `CustomFieldValue` | `custom_field_value` |
| Document | `Document` | `document` |
| Commentaire | `Comment` | `comment` |
| Journal d'audit | `AuditLog` | `audit_log` |
| Utilisateur | `Profile` | `profile` |
| Lien machine-composant | `MachineComponentLink` | `machine_component_link` |
| Lien machine-pièce | `MachinePieceLink` | `machine_piece_link` |
| Lien machine-produit | `MachineProductLink` | `machine_product_link` |

346
docs/REVIEW_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,346 @@
# Revue d'architecture - Sources de complexite et effets de bord
Date : 2026-03-23
Branche analysee : `develop`
---
## Diagnostic - Top 10 des sources de complexite
| # | Source | Impact | Effort |
|---|--------|--------|--------|
| 1 | Duplication massive du `smartMatch` dans les Sync Strategies | Bugs silencieux, maintenance triple | M |
| 2 | Custom Fields : 4 FK nullable sur une seule entite (polymorphisme pauvre) | Integrite fragile, code defensif partout | L |
| 3 | Composables frontend geants avec responsabilites multiples | Difficile a tester, refactoring risque | M |
| 4 | 3 fichiers utils de custom fields frontend avec logique qui se chevauche | Incoherences, bugs de merge/dedup | M |
| 5 | `pendingStructure` : canal de communication cache entre deserialisation et processor | Effet de bord invisible, timing fragile | S |
| 6 | `PieceProductSyncSubscriber` : legacy sync dans un subscriber Doctrine | Side effect cache, recompute du changeset | S |
| 7 | Double flush dans les processors (decorated + flush manuel) | Audit logs potentiellement incomplets | S |
| 8 | `MachineStructureController` : God controller avec normalisation JSON manuelle | Bypass API Platform, 300+ LOC de serialisation | L |
| 9 | Chaine de dependances circulaire dans `useMachineDetailData` | Proxy refs, ordre d'initialisation fragile | M |
| 10 | Frontend : typage `any` systematique sur les entites | Pas de filet de securite TypeScript | L |
---
## Analyse detaillee
### 1. Duplication du `smartMatch` dans les Sync Strategies
**Fichiers concernes :**
- `/src/Service/Sync/ComposantSyncStrategy.php` (lignes 380-446)
- `/src/Service/Sync/PieceSyncStrategy.php` (lignes 244-308)
**Probleme :** `smartMatch()`, `smartMatchPreview()` et toute la logique de sync des custom field values sont copiees-collees entre `ComposantSyncStrategy` et `PieceSyncStrategy`. Le `ProductSyncStrategy` a une version simplifiee (pas de slots). Si un bug est corrige dans l'un, il faut penser a le corriger dans l'autre.
**Effets de bord concrets :**
- Un correctif sur le matching des slots dans une strategie peut etre oublie dans l'autre
- Le compteur de preview custom fields utilise `orderIndex` comme cle de matching, ce qui est fragile (reindexation = faux positif)
**Solution proposee (effort M) :**
Extraire un trait ou une classe abstraite `AbstractSlotSyncStrategy` :
```php
// AVANT : smartMatch() duplique dans ComposantSyncStrategy et PieceSyncStrategy
// APRES : extraire dans un trait
trait SlotSyncTrait
{
protected function smartMatch(array $existingTypeIds, array $proposedTypeIds): array
{
// ... logique unique
}
protected function syncCustomFieldValues(
object $entity,
string $fkField,
array $customFields,
bool $confirmDeletions,
): array {
// ... logique unique pour add/remove CFValues
}
}
```
La methode `execute()` de chaque strategie ne garderait que la boucle specifique a son type de slot (piece slots, product slots, subcomponent slots), et deleguerait le matching et la gestion des CF values au trait.
---
### 2. Custom Fields : polymorphisme par FK nullable
**Fichiers concernes :**
- `/src/Entity/CustomField.php` - 4 FK nullable : `machine`, `typeComposant`, `typePiece`, `typeProduct`
- `/src/Entity/CustomFieldValue.php` - 4 FK nullable : `machine`, `composant`, `piece`, `product`
- `/src/Controller/CustomFieldValueController.php` - `resolveTarget()` fait un switch sur 4 types
**Probleme :** Un `CustomFieldValue` peut pointer vers machine OU composant OU piece OU produit via 4 colonnes nullable. Rien n'empeche au niveau DB qu'un CFV pointe vers deux entites en meme temps. Le frontend doit deviner le type cible. Chaque nouveau type d'entite necessite d'ajouter une colonne, un setter, et un cas dans tous les switches.
**Effets de bord concrets :**
- Le `CustomFieldValueController::resolveTarget()` tente 4 cles dans un ordre specifique -- si le payload a `machineId` ET `composantId`, seul `machine` est utilise (silent bug)
- Les audit subscribers (`getOwnerFromCustomFieldValue`) doivent tester chaque getter -- si `getComposant()` renvoie un objet alors que `getMachine()` aussi, le comportement est indetermine
- La serialisation API Platform expose les 4 FK meme quand 3 sont null
**Solution proposee (effort L) :**
Option pragmatique (pas de refactoring DB) : ajouter une colonne discriminante `entityType` (enum) + contrainte CHECK :
```sql
ALTER TABLE custom_field_values
ADD COLUMN entity_type VARCHAR(20) NOT NULL DEFAULT 'machine';
ALTER TABLE custom_field_values
ADD CONSTRAINT chk_single_fk CHECK (
(entity_type = 'machine' AND machineId IS NOT NULL AND composantId IS NULL AND pieceId IS NULL AND productId IS NULL) OR
(entity_type = 'composant' AND composantId IS NOT NULL AND machineId IS NULL AND pieceId IS NULL AND productId IS NULL) OR
(entity_type = 'piece' AND pieceId IS NOT NULL AND machineId IS NULL AND composantId IS NULL AND productId IS NULL) OR
(entity_type = 'product' AND productId IS NOT NULL AND machineId IS NULL AND composantId IS NULL AND pieceId IS NULL)
);
```
Cela securise l'integrite sans changer l'architecture. Le `resolveTarget` et les audit subscribers pourraient ensuite brancher sur `entityType` au lieu de tester 4 FK.
---
### 3. Composables frontend geants (400-550 LOC)
**Fichiers concernes :**
- `/Inventory_frontend/app/composables/useComponentEdit.ts` (550 LOC)
- `/Inventory_frontend/app/composables/usePieceEdit.ts` (472 LOC)
- `/Inventory_frontend/app/composables/useMachineDetailData.ts` (468 LOC)
- `/Inventory_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.
**Effets de bord concrets :**
- `useComponentEdit` instancie `usePieces()`, `useProducts()`, `useComposants()` a chaque montage de page, meme si ces catalogues sont deja charges -- requetes API en double
- La logique de soumission (`submitEdition`, `submitCreation`) melange la construction du payload, la validation, l'appel API, et la persistence des custom fields -- si une etape echoue, l'etat local est partiellement modifie
- Les watchers sur `selectedType`/`selectedTypeStructure` dans `useComponentCreate` et `useComponentEdit` font des choses differentes pour le meme concept -- source de divergence
**Solution proposee (effort M) :**
Decouper chaque composable geant en sous-composables par responsabilite, comme deja fait pour `useMachineDetailData` (qui delegue a `useMachineDetailDocuments`, `useMachineDetailCustomFields`, etc.) :
```
useComponentEdit.ts (550 LOC)
-> useComponentEditForm.ts (~100 LOC : reactive form, validation)
-> useComponentEditDocuments.ts (~80 LOC : upload, preview, delete)
-> useComponentEditSlots.ts (~120 LOC : slot selection/save)
-> useComponentEditCustomFields.ts (~60 LOC : build inputs, save)
-> useComponentEdit.ts (~150 LOC : orchestrateur)
```
Appliquer le meme pattern a `usePieceEdit` et `useComponentCreate`. Les blocs communs (document handling, custom field save, price formatting) deviendraient des composables partages.
---
### 4. Triple duplication de la logique custom fields frontend
**Fichiers concernes :**
- `/Inventory_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
- `/Inventory_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 :
- `customFieldFormUtils.ts` : `resolveFieldName()`, `resolveFieldType()`, `buildCustomFieldInputs()`
- `entityCustomFieldLogic.ts` : `resolveFieldName()` (differente!), `resolveFieldType()` (differente!), `mergeFieldDefinitionsWithValues()`
- `customFieldUtils.ts` : `extractDefinitionName()`, `normalizeExistingCustomFieldDefinitions()`, `mergeCustomFieldValuesWithDefinitions()`
**Effets de bord concrets :**
- Trois facons differentes de resoudre le nom d'un champ -- `resolveFieldName` dans `customFieldFormUtils` teste `name`, `key`, `label` ; dans `entityCustomFieldLogic` elle teste `name` seulement et retourne `'Champ'` par defaut
- Trois algorithmes de merge values/definitions -- un bug corrige dans l'un n'est pas corrige dans les autres
- La deduplication par `name+type` dans `entityCustomFieldLogic.ts` et par `orderIndex` dans `customFieldUtils.ts` produit des resultats differents pour les memes donnees
**Solution proposee (effort M) :**
Fusionner en un seul module `customFields.ts` avec :
1. Une seule fonction `resolveFieldName(field: any): string`
2. Une seule fonction `mergeDefinitionsWithValues(defs, values): MergedField[]`
3. Une seule fonction `deduplicateFields(fields): MergedField[]`
Les 3 fichiers actuels deviendraient des re-exports ou des wrappers fins. Commencer par aligner les signatures, puis remplacer les imports un par un.
---
### 5. `pendingStructure` : canal de communication cache
**Fichiers concernes :**
- `/src/Entity/ModelType.php` et `/src/Entity/Composant.php` -- propriete `#[ApiProperty]` non mappee en DB
- `/src/State/ModelTypeProcessor.php` (lignes 33-43)
- `/src/State/ComposantProcessor.php` (lignes 42-51)
**Probleme :** Le champ `structure` envoye par le frontend est intercepte par API Platform dans un champ `pendingStructure` (non mappe en DB), puis lu par le processor apres le `persist` du decorated processor. Ce mecanisme est invisible : rien dans l'entite n'indique qu'un setter a un effet de bord differe.
**Effets de bord concrets :**
- Si le `decorated->process()` leve une exception, le `pendingStructure` reste dans l'entite -- pas de cleanup
- Le `flush()` supplementaire dans le processor (ligne 43 de `ModelTypeProcessor`) declenche les audit subscribers une deuxieme fois pour le meme cycle de request -- les snapshots d'audit peuvent capturer un etat intermediaire
- Un developpeur qui modifie le `ModelType` via Doctrine directement (fixture, migration, CLI) ne beneficie pas de ce mecanisme -- les skeleton requirements ne sont pas mis a jour
**Solution proposee (effort S) :**
Documenter explicitement ce pattern dans l'entite avec un docblock. Ajouter un `try/finally` pour le cleanup :
```php
// ModelTypeProcessor::process()
try {
$result = $this->decorated->process($data, $operation, $uriVariables, $context);
if (null !== $pendingStructure) {
$this->skeletonStructureService->updateSkeletonRequirements($data, $pendingStructure);
$this->entityManager->flush();
}
return $result;
} finally {
$data->clearPendingStructure();
}
```
---
### 6. `PieceProductSyncSubscriber` : side effect cache
**Fichier concerne :**
- `/src/EventSubscriber/PieceProductSyncSubscriber.php`
**Probleme :** Ce subscriber Doctrine ecoute `prePersist` et `preUpdate` pour synchroniser la relation legacy `product` (ManyToOne) avec la collection `productIds` (JSON array). Sur `preUpdate`, il fait un `recomputeSingleEntityChangeSet` (ligne 50-51), ce qui modifie le changeset en cours de flush.
**Effets de bord concrets :**
- Le recompute du changeset peut interferer avec les audit subscribers qui lisent ce meme changeset -- l'audit log peut capturer le changement de `product` comme une modification manuelle alors qu'il est automatique
- L'ordre d'execution des subscribers n'est pas garanti -- si l'audit subscriber s'execute avant le sync, il ne voit pas le changement de `product`
- Si `productIds` est vide, le subscriber ne touche pas `product` -- mais si `product` avait deja une valeur, elle reste (pas de cleanup)
**Solution proposee (effort S) :**
Remplacer ce subscriber par une logique explicite dans le controller/processor qui traite les pieces. Le sync `productIds -> product` devrait etre fait AVANT le flush, pas dans un subscriber. Cela supprime l'ambiguite sur l'ordre d'execution et le recompute.
Alternativement, si la relation legacy `product` (ManyToOne) n'est plus utilisee par le frontend, la supprimer completement et ne garder que `productIds` / les product slots.
---
### 7. Double flush dans les processors
**Fichiers concernes :**
- `/src/State/ModelTypeProcessor.php` (ligne 36 via decorated, ligne 43 manuellement)
- `/src/State/ComposantProcessor.php` (ligne 45 via decorated, ligne 132 manuellement)
**Probleme :** Le decorated processor fait un `flush()` pour persister l'entite, puis un second `flush()` est appele pour persister les skeleton requirements ou slots. Chaque flush declenche `onFlush` dans tous les audit subscribers.
**Effets de bord concrets :**
- Le premier flush capture le `create` de l'entite dans l'audit log
- Le second flush peut generer un `update` de la meme entite si les slots ont modifie une relation qui declenche un dirty check (par ex. si `$composant->incrementVersion()` etait appele)
- En cas d'erreur entre les deux flush, l'entite est persistee mais ses slots ne le sont pas -- etat inconsistant
**Solution proposee (effort S) :**
Wrapper les deux operations dans une transaction explicite, et ne faire qu'un seul flush a la fin :
```php
public function process(mixed $data, Operation $operation, ...): mixed
{
return $this->entityManager->wrapInTransaction(function () use ($data, $operation, ...) {
// Ne pas flush dans le decorated -- utiliser le mode COMMIT_ON_CLOSE
$result = $this->decorated->process($data, $operation, $uriVariables, $context);
if (null !== $pendingStructure) {
$this->skeletonStructureService->updateSkeletonRequirements($data, $pendingStructure);
}
$data->clearPendingStructure();
// Un seul flush
$this->entityManager->flush();
return $result;
});
}
```
> Note : cela necessite de verifier que le decorated processor ne fait pas deja un flush interne non configurable. Si c'est le cas, il faudrait potentiellement ne pas utiliser le decorated et gerer le persist manuellement.
---
### 8. `MachineStructureController` : God controller
**Fichier concerne :**
- `/src/Controller/MachineStructureController.php` (300+ LOC)
**Probleme :** Ce controller gere GET structure, PATCH structure, et POST clone. Il contient toute la logique de normalisation JSON des links (component, piece, product), la resolution des entites, et la serialisation manuelle de la reponse -- tout ce qu'API Platform fait normalement automatiquement.
**Effets de bord concrets :**
- La normalisation JSON manuelle (`normalizeStructureResponse`) ne passe pas par les serialization groups d'API Platform -- si un champ est ajoute a une entite avec un group, il n'apparaitra pas dans la reponse structure
- Le PATCH structure fait `$this->entityManager->flush()` sans transaction -- si la creation d'un link echoue, les precedents sont deja persistes
- Le clone copie les custom fields mais pas les documents -- comportement potentiellement inattendu
- 8 repositories injectes dans le constructeur -- code smell
**Solution proposee (effort L) :**
1. Extraire la logique de normalisation dans un `MachineStructureSerializer` service
2. Extraire la logique de clone dans un `MachineCloneService`
3. Wrapper le PATCH et le clone dans des transactions
4. A terme, considerer un DTO + custom provider/processor API Platform pour le GET/PATCH structure
---
### 9. Dependance circulaire dans `useMachineDetailData`
**Fichier concerne :**
- `/Inventory_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.
**Effets de bord concrets :**
- Le proxy ref est mis a jour de facon asynchrone via un watcher -- pendant le premier tick de rendu, `_machineProductLinksProxy` est vide meme si les liens sont deja charges
- L'ordre d'initialisation des sous-composables est fragile -- deplacer une ligne peut casser la boucle
- Le commentaire dans le code (lignes 119-122) admet explicitement le probleme
**Solution proposee (effort M) :**
Inverser la dependance : le composable `useMachineDetailHierarchy` devrait etre le seul a gerer les links et exposer les product links. `useMachineDetailProducts` ne devrait recevoir que les product IDs (pas les links complets). Cela supprime la circularite.
Alternativement, creer un `useMachineDetailState` purement reactif (store local) qui contient tous les refs partages, et le passer aux sous-composables. Cela explicite les dependances.
---
### 10. Typage `any` systematique sur les entites frontend
**Fichiers concernes :** Quasi tous les composables utilisent `ref<any | null>(null)` pour les entites :
- `useComponentEdit.ts` : `const component = ref<any | null>(null)` (ligne 74)
- `usePieceEdit.ts` : `const piece = ref<any | null>(null)` (ligne 56)
- `useMachineDetailData.ts` : `type AnyRecord = Record<string, unknown>`
**Probleme :** Les reponses API ne sont jamais typees. L'acces aux proprietes se fait par convention (`result.data?.structure?.pieces`) sans aucune validation. TypeScript ne peut pas detecter les typos ou les acces a des proprietes inexistantes.
**Effets de bord concrets :**
- Un changement de nom de champ cote API ne provoque aucune erreur TypeScript -- le bug n'est decouvert qu'au runtime
- L'autocompletion IDE est inutile sur ces objets
- Les defensives checks (`Array.isArray(x?.y) ? x.y : []`) sont necessaires partout parce que le type ne garantit rien
**Solution proposee (effort L) :**
1. Creer des interfaces TypeScript pour les reponses API principales : `MachineStructureResponse`, `ComposantResponse`, `PieceResponse`, `ProductResponse`, `ModelTypeResponse`
2. Ajouter une couche de validation a la reception dans `useApi.ts` (optionnelle, avec Zod ou un type guard maison)
3. Remplacer progressivement `ref<any>` par `ref<ComposantResponse | null>`
Commencer par les entites les plus utilisees (Machine, Composant) pour obtenir un benefice immediat.
---
## Plan de simplification -- Ordre recommande
### Phase 1 : Quick wins (1-2 jours chacun, impact immediat)
| # | Action | Source | Effort |
|---|--------|--------|--------|
| 1 | Extraire `smartMatch` + sync CF values dans un trait partage | Source 1 | S |
| 2 | Ajouter `try/finally` sur `clearPendingStructure` | Source 5 | S |
| 3 | Remplacer `PieceProductSyncSubscriber` par logique explicite | Source 6 | S |
| 4 | Wrapper les processors dans des transactions | Source 7 | S |
### Phase 2 : Unification frontend (1-2 semaines)
| # | Action | Source | Effort |
|---|--------|--------|--------|
| 5 | Fusionner les 3 fichiers custom fields utils en un seul | Source 4 | M |
| 6 | Decouper `useComponentEdit` / `usePieceEdit` en sous-composables | Source 3 | M |
| 7 | Resoudre la circularite dans `useMachineDetailData` | Source 9 | M |
### Phase 3 : Renforcement structurel (2-4 semaines)
| # | Action | Source | Effort |
|---|--------|--------|--------|
| 8 | Ajouter la contrainte CHECK sur `custom_field_values` | Source 2 | M |
| 9 | Typer les reponses API principales | Source 10 | L |
| 10 | Extraire services depuis `MachineStructureController` | Source 8 | L |
### Principe directeur
**Commencer par la phase 1** -- elle ne modifie pas les interfaces (ni API ni frontend) et supprime les effets de bord les plus dangereux. La phase 2 est une consolidation frontend qui peut etre faite page par page. La phase 3 est un investissement a plus long terme.
Ne pas tenter de tout refactorer en une fois. Chaque item peut etre un PR isole, testable independamment.

185
docs/mcp/README.md Normal file
View File

@@ -0,0 +1,185 @@
# MCP Server — Inventory
Serveur MCP (Model Context Protocol) pour l'application Inventory. Permet aux assistants IA (Claude, ChatGPT, Codex) de consulter et gérer l'inventaire industriel.
## Prérequis
- Un profil actif avec rôle suffisant (ROLE_VIEWER pour lecture, ROLE_GESTIONNAIRE pour écriture)
- Accès au tunnel pour les clients distants (Claude Desktop, ChatGPT Desktop)
- Docker Compose démarré (`make start`)
## Configuration par client
### Claude Code (local, stdio)
Le fichier `.mcp.json` à la racine du projet est déjà configuré. Remplacez les placeholders :
```json
{
"mcpServers": {
"inventory": {
"command": "docker",
"args": [
"exec", "-i",
"-e", "MCP_PROFILE_ID=VOTRE_PROFILE_ID",
"-e", "MCP_PROFILE_PASSWORD=VOTRE_PASSWORD",
"php-inventory-apache",
"php", "bin/console", "mcp:server"
]
}
}
}
```
### Claude Desktop (HTTP via tunnel)
Dans `claude_desktop_config.json` :
```json
{
"mcpServers": {
"inventory": {
"url": "https://inventory.company-tunnel.com/_mcp",
"headers": {
"X-Profile-Id": "VOTRE_PROFILE_ID",
"X-Profile-Password": "VOTRE_PASSWORD"
}
}
}
}
```
### ChatGPT Desktop / Codex
Meme principe HTTP avec l'URL du tunnel + headers d'auth.
## Catalogue des Tools
### Tools de haut niveau
| Tool | Description | Role |
|------|-------------|------|
| `search_inventory` | Recherche globale (machines, pieces, composants, produits, sites, constructeurs) | VIEWER |
| `get_machine_structure` | Hierarchie complete d'une machine | VIEWER |
| `clone_machine` | Clone une machine avec toute sa structure | GESTIONNAIRE |
| `get_dashboard_stats` | Statistiques globales | VIEWER |
| `get_entity_history` | Historique d'audit d'une entite | VIEWER |
| `get_activity_log` | Journal d'activite global | VIEWER |
### CRUD par entite
Pour chaque entite (Machine, Composant, Piece, Produit, Site, Constructeur) :
| Pattern | Exemple | Role |
|---------|---------|------|
| `list_{entite}s` | `list_machines` | VIEWER |
| `get_{entite}` | `get_machine` | VIEWER |
| `create_{entite}` | `create_machine` | GESTIONNAIRE |
| `update_{entite}` | `update_machine` | GESTIONNAIRE |
| `delete_{entite}` | `delete_machine` | GESTIONNAIRE |
### Slots
| Tool | Description | Role |
|------|-------------|------|
| `list_slots` | Lister les slots d'un composant ou piece | VIEWER |
| `update_slots` | Remplir/vider les slots | GESTIONNAIRE |
### Machine Links
| Tool | Description | Role |
|------|-------------|------|
| `list_machine_links` | Liens composant/piece/produit d'une machine | VIEWER |
| `add_machine_links` | Ajouter des liens | GESTIONNAIRE |
| `update_machine_link` | Modifier un lien | GESTIONNAIRE |
| `remove_machine_link` | Supprimer un lien | GESTIONNAIRE |
### Commentaires
| Tool | Description | Role |
|------|-------------|------|
| `list_comments` | Lister les commentaires d'une entite | VIEWER |
| `create_comment` | Creer un commentaire | VIEWER |
| `resolve_comment` | Resoudre un commentaire | GESTIONNAIRE |
| `get_unresolved_comments_count` | Nombre de commentaires non resolus | VIEWER |
### Custom Fields
| Tool | Description | Role |
|------|-------------|------|
| `list_custom_field_values` | Valeurs de champs perso d'une entite | VIEWER |
| `upsert_custom_field_values` | Creer/mettre a jour des valeurs | GESTIONNAIRE |
| `delete_custom_field_value` | Supprimer une valeur | GESTIONNAIRE |
### Documents
| Tool | Description | Role |
|------|-------------|------|
| `list_documents` | Lister les documents d'une entite | VIEWER |
| `delete_document` | Supprimer un document | GESTIONNAIRE |
> **Limitation :** L'upload de documents n'est pas supporte via MCP (protocole JSON uniquement). Utilisez l'API REST `/api/documents` (POST multipart).
### ModelTypes
| Tool | Description | Role |
|------|-------------|------|
| `list_model_types` | Lister par categorie | VIEWER |
| `get_model_type` | Detail avec skeleton requirements | VIEWER |
| `create_model_type` | Creer | GESTIONNAIRE |
| `update_model_type` | Modifier | GESTIONNAIRE |
| `delete_model_type` | Supprimer | GESTIONNAIRE |
| `sync_model_type` | Preview/sync skeleton | GESTIONNAIRE |
## Workflows guides
### Creer un composant complet
```
1. list_model_types(category: "composant") -> choisir le type
2. get_model_type(modelTypeId: "...") -> voir le skeleton
3. create_composant(name, reference, modelTypeId) -> cree + slots auto
4. search_inventory(query: "Roulement", types: "piece") -> trouver pieces
5. update_slots(slots: [{slotId, selectedPieceId}]) -> remplir
6. upsert_custom_field_values(entityType: "composant", entityId, fields: [...])
```
### Creer une machine complete (bottom-up)
```
1. Creer les produits necessaires
2. Creer les pieces (avec produits dans les slots)
3. Creer les composants (avec pieces dans les slots)
4. list_sites -> choisir le site
5. create_machine(name, siteId)
6. add_machine_links(machineId, links: [{type: "composant", entityId, quantity}])
7. upsert_custom_field_values(entityType: "machine", machineId, fields: [...])
```
## Resources MCP
| URI | Description |
|-----|-------------|
| `inventory://schema/entities` | Schema de toutes les entites |
| `inventory://roles` | Hierarchie des roles et permissions |
| `inventory://stats` | Statistiques globales |
## Roles & Permissions
```
ROLE_ADMIN > ROLE_GESTIONNAIRE > ROLE_VIEWER > ROLE_USER
```
- **VIEWER** : lecture, recherche, commentaires
- **GESTIONNAIRE** : ecriture (CRUD, slots, links, clone)
- **ADMIN** : gestion profils (via API REST uniquement)
## Troubleshooting
| Erreur | Cause | Solution |
|--------|-------|----------|
| `401 Unauthorized` | Credentials invalides | Verifier X-Profile-Id et X-Profile-Password |
| `Permission denied: ROLE_GESTIONNAIRE required` | Role insuffisant | Utiliser un profil avec le bon role |
| `Rate limited` | Trop de tentatives echouees | Attendre 1 minute |
| `Tool not found` | Tool non enregistre | Verifier que le cache est a jour (`cache:clear`) |
| `Error while executing tool` | Erreur interne | Verifier les logs et les parametres |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,871 @@
# Comment Document Attachments — 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:** Allow users to attach one or more documents when creating a comment, via a single multipart/form-data request.
**Architecture:** Add a `comment` ManyToOne on Document entity (same pattern as machine/site/etc.), modify `CommentController::create()` to accept multipart/form-data with files + text fields, store files via existing `DocumentStorageService`, and update the frontend `CommentSection.vue` to include a file picker.
**Tech Stack:** Symfony 8, Doctrine, API Platform, Vue 3 Composition API, TypeScript, TailwindCSS/DaisyUI
---
### Task 1: Migration — add `comment_id` FK on `documents`
**Files:**
- Create: `migrations/Version20260323160000.php`
- [ ] **Step 1: Create the migration**
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260323160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add comment_id FK on documents table';
}
public function up(Schema $schema): void
{
$this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'comment_id') THEN ALTER TABLE documents ADD COLUMN comment_id VARCHAR(36) DEFAULT NULL; END IF; END $$");
$this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_documents_comment') THEN ALTER TABLE documents ADD CONSTRAINT fk_documents_comment FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE; END IF; END $$");
$this->addSql("CREATE INDEX IF NOT EXISTS idx_documents_comment_id ON documents(comment_id)");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_comment');
$this->addSql('DROP INDEX IF EXISTS idx_documents_comment_id');
$this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS comment_id');
}
}
```
- [ ] **Step 2: Run the migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration executes successfully.
- [ ] **Step 3: Update test schema**
Run: `make test-setup`
- [ ] **Step 4: Commit**
```bash
git add migrations/Version20260323160000.php
git commit -m "feat(documents) : add comment_id FK on documents table"
```
---
### Task 2: Entity updates — Document.comment + Comment.documents
**Files:**
- Modify: `src/Entity/Document.php`
- Modify: `src/Entity/Comment.php`
- [ ] **Step 1: Add `comment` ManyToOne on Document entity**
In `src/Entity/Document.php`, add after the `$site` property (around line 109):
```php
#[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Comment $comment = null;
```
And add getter/setter:
```php
public function getComment(): ?Comment
{
return $this->comment;
}
public function setComment(?Comment $comment): static
{
$this->comment = $comment;
return $this;
}
```
- [ ] **Step 2: Add `documents` OneToMany on Comment entity**
In `src/Entity/Comment.php`, add the import:
```php
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
```
Add property after `$updatedAt`:
```php
/** @var Collection<int, Document> */
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])]
private Collection $documents;
```
Initialize in constructor:
```php
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->documents = new ArrayCollection();
}
```
Add getter:
```php
/** @return Collection<int, Document> */
public function getDocuments(): Collection
{
return $this->documents;
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 4: Run tests to check nothing broke**
Run: `make test`
Expected: All existing tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/Entity/Document.php src/Entity/Comment.php
git commit -m "feat(documents) : add Comment-Document relationship (ManyToOne/OneToMany)"
```
---
### Task 3: Update CommentController to accept multipart/form-data with files
**Files:**
- Modify: `src/Controller/CommentController.php`
- [ ] **Step 1: Add DocumentStorageService dependency and update create() method**
Update constructor to inject `DocumentStorageService`:
```php
use App\Entity\Document;
use App\Enum\DocumentType;
use App\Service\DocumentStorageService;
use Symfony\Component\HttpFoundation\File\UploadedFile;
```
```php
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProfileRepository $profiles,
private readonly DocumentStorageService $storageService,
) {}
```
Replace the `create()` method body to handle both JSON and multipart:
```php
#[Route('', name: 'api_comments_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$session = $request->getSession();
$profileId = $session->get('profileId');
if (!$profileId) {
return $this->json(['message' => 'Aucun profil actif.'], 401);
}
$profile = $this->profiles->find($profileId);
if (!$profile) {
return $this->json(['message' => 'Profil introuvable.'], 401);
}
// Parse fields from JSON or form-data
$contentType = $request->headers->get('Content-Type', '');
if (str_contains($contentType, 'multipart/form-data')) {
$content = trim((string) $request->request->get('content', ''));
$entityType = trim((string) $request->request->get('entityType', ''));
$entityId = trim((string) $request->request->get('entityId', ''));
$entityName = $request->request->get('entityName') ? trim((string) $request->request->get('entityName')) : null;
} else {
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return $this->json(['message' => 'Payload JSON invalide.'], 400);
}
$content = trim((string) ($payload['content'] ?? ''));
$entityType = trim((string) ($payload['entityType'] ?? ''));
$entityId = trim((string) ($payload['entityId'] ?? ''));
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
}
if ('' === $content) {
return $this->json(['message' => 'Le contenu est requis.'], 400);
}
$allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton'];
if (!in_array($entityType, $allowedTypes, true)) {
return $this->json(['message' => 'Type d\'entité invalide.'], 400);
}
if ('' === $entityId) {
return $this->json(['message' => 'L\'identifiant de l\'entité est requis.'], 400);
}
$authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $authorName) {
$authorName = $profile->getEmail() ?? 'Inconnu';
}
$comment = new Comment();
$comment->setContent($content);
$comment->setEntityType($entityType);
$comment->setEntityId($entityId);
$comment->setEntityName($entityName);
$comment->setAuthorId($profileId);
$comment->setAuthorName($authorName);
$this->entityManager->persist($comment);
// Handle file uploads
/** @var UploadedFile[] $files */
$files = $request->files->all('files');
foreach ($files as $file) {
if (!$file instanceof UploadedFile || !$file->isValid()) {
continue;
}
$document = new Document();
$documentId = 'cl'.bin2hex(random_bytes(12));
$document->setId($documentId);
$document->setName($file->getClientOriginalName());
$document->setFilename($file->getClientOriginalName());
$document->setMimeType($file->getMimeType() ?: 'application/octet-stream');
$document->setSize((int) $file->getSize());
$document->setType(DocumentType::DOCUMENTATION);
$document->setComment($comment);
$extension = $this->storageService->extensionFromFilename($file->getClientOriginalName());
$relativePath = $this->storageService->storeFromPath(
$file->getPathname(),
$documentId,
$extension,
);
$document->setPath($relativePath);
$this->entityManager->persist($document);
}
$this->entityManager->flush();
return $this->json($this->normalize($comment), 201);
}
```
- [ ] **Step 2: Update normalize() to include documents**
```php
private function normalize(Comment $comment): array
{
$documents = [];
foreach ($comment->getDocuments() as $document) {
$documents[] = [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'type' => $document->getType()->value,
'fileUrl' => '/api/documents/'.$document->getId().'/file',
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
'createdAt' => $document->getCreatedAt()->format(DateTimeInterface::ATOM),
];
}
return [
'id' => $comment->getId(),
'content' => $comment->getContent(),
'entityType' => $comment->getEntityType(),
'entityId' => $comment->getEntityId(),
'entityName' => $comment->getEntityName(),
'authorId' => $comment->getAuthorId(),
'authorName' => $comment->getAuthorName(),
'status' => $comment->getStatus(),
'resolvedById' => $comment->getResolvedById(),
'resolvedByName' => $comment->getResolvedByName(),
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
'documents' => $documents,
];
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 4: Run tests**
Run: `make test`
Expected: All existing tests still pass (they use JSON, not multipart).
- [ ] **Step 5: Commit**
```bash
git add src/Controller/CommentController.php
git commit -m "feat(comments) : accept multipart/form-data with file uploads on create"
```
---
### Task 4: Update DocumentUploadProcessor and DocumentQueryController
**Files:**
- Modify: `src/State/DocumentUploadProcessor.php`
- Modify: `src/Controller/DocumentQueryController.php`
- [ ] **Step 1: Add `commentId` to DocumentUploadProcessor relation map**
In `src/State/DocumentUploadProcessor.php`, update `$relationMap` in `setRelationsFromRequest()`:
```php
$relationMap = [
'machineId' => 'Machine',
'composantId' => 'Composant',
'pieceId' => 'Piece',
'productId' => 'Product',
'siteId' => 'Site',
'commentId' => 'Comment',
];
```
- [ ] **Step 2: Add comment route to DocumentQueryController**
Add `CommentRepository` import and inject it, then add the route:
```php
use App\Repository\CommentRepository;
```
Add to constructor:
```php
private readonly CommentRepository $commentRepository,
```
Wait — `Comment` has no repository. Use the EntityManager instead. Add the route method:
```php
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$comment = $this->getEntityManager()->getRepository(\App\Entity\Comment::class)->find($id);
if (!$comment) {
return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
}
$documents = $this->documentRepository->findBy(['comment' => $comment]);
return $this->json($this->normalizeDocuments($documents));
}
```
Actually, the controller doesn't have `getEntityManager()`. Use `DocumentRepository` directly:
```php
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$documents = $this->documentRepository->findBy(['comment' => $id]);
return $this->json($this->normalizeDocuments($documents));
}
```
Wait — `findBy(['comment' => $id])` won't work with a string ID directly on a relation. Let me use the pattern from the existing code and add the Comment entity lookup. The simplest approach: inject `EntityManagerInterface`.
Actually, looking at the existing pattern more carefully, the other methods fetch the entity first and pass the object. We can use the documentRepository's entity manager. Let's just follow the exact same pattern and add a dependency. But actually, let's keep it simple — the documents table has `comment_id` column, so we can use a custom query. The simplest: just inject EntityManagerInterface.
```php
use Doctrine\ORM\EntityManagerInterface;
```
Add to constructor: `private readonly EntityManagerInterface $em,`
```php
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$comment = $this->em->find(\App\Entity\Comment::class, $id);
if (!$comment) {
return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
}
$documents = $this->documentRepository->findBy(['comment' => $comment]);
return $this->json($this->normalizeDocuments($documents));
}
```
- [ ] **Step 3: Update normalizeDocuments to include commentId**
Add to the normalizeDocuments return array:
```php
'commentId' => $document->getComment()?->getId(),
```
- [ ] **Step 4: Run php-cs-fixer + tests**
Run: `make php-cs-fixer-allow-risky && make test`
- [ ] **Step 5: Commit**
```bash
git add src/State/DocumentUploadProcessor.php src/Controller/DocumentQueryController.php
git commit -m "feat(documents) : add comment support in upload processor and query controller"
```
---
### Task 5: Backend tests — comment with documents
**Files:**
- Modify: `tests/Api/Controller/CommentControllerTest.php`
- [ ] **Step 1: Add test for creating comment with files**
```php
public function testCreateCommentWithFiles(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
// Create a temporary file for upload
$tmpFile = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tmpFile, 'test file content');
$uploadedFile = new \Symfony\Component\HttpFoundation\File\UploadedFile(
$tmpFile,
'test-doc.pdf',
'application/pdf',
null,
true,
);
$client->request('POST', '/api/comments', [
'headers' => ['Content-Type' => 'multipart/form-data'],
'extra' => [
'parameters' => [
'content' => 'Comment with file',
'entityType' => 'machine',
'entityId' => $machine->getId(),
'entityName' => 'Machine A',
],
'files' => [
'files' => [$uploadedFile],
],
],
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('Comment with file', $data['content']);
$this->assertCount(1, $data['documents']);
$this->assertSame('test-doc.pdf', $data['documents'][0]['filename']);
@unlink($tmpFile);
}
```
- [ ] **Step 2: Add test for creating comment with multiple files**
```php
public function testCreateCommentWithMultipleFiles(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
$tmpFile1 = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tmpFile1, 'content 1');
$tmpFile2 = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tmpFile2, 'content 2');
$file1 = new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpFile1, 'doc1.pdf', 'application/pdf', null, true);
$file2 = new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpFile2, 'doc2.png', 'image/png', null, true);
$client->request('POST', '/api/comments', [
'extra' => [
'parameters' => [
'content' => 'Multiple files',
'entityType' => 'machine',
'entityId' => $machine->getId(),
],
'files' => [
'files' => [$file1, $file2],
],
],
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertCount(2, $data['documents']);
@unlink($tmpFile1);
@unlink($tmpFile2);
}
```
- [ ] **Step 3: Add test that existing JSON create still works and returns empty documents array**
```php
public function testCreateCommentJsonStillReturnsDocuments(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
$client->request('POST', '/api/comments', [
'json' => [
'content' => 'No files',
'entityType' => 'machine',
'entityId' => $machine->getId(),
],
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame([], $data['documents']);
}
```
- [ ] **Step 4: Run tests**
Run: `make test`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add tests/Api/Controller/CommentControllerTest.php
git commit -m "test(comments) : add tests for comment creation with file attachments"
```
---
### Task 6: Frontend — update useComments composable
**Files:**
- Modify: `Inventory_frontend/app/composables/useComments.ts`
- [ ] **Step 1: Add document type to Comment interface**
```typescript
export interface CommentDocument {
id: string
name: string
filename: string
mimeType: string
size: number
type: string
fileUrl: string
downloadUrl: string
createdAt: string
}
export interface Comment {
id: string
content: string
entityType: string
entityId: string
entityName?: string | null
authorId: string
authorName: string
status: 'open' | 'resolved'
resolvedById?: string | null
resolvedByName?: string | null
resolvedAt?: string | null
createdAt: string
updatedAt: string
documents: CommentDocument[]
}
```
- [ ] **Step 2: Update createComment to accept files and use FormData**
Add `postFormData` to the destructured `useApi()` call:
```typescript
const { get, post, patch, postFormData, delete: del } = useApi()
```
Update `createComment`:
```typescript
const createComment = async (
entityType: string,
entityId: string,
content: string,
entityName?: string,
files?: File[],
): Promise<CommentResult> => {
loading.value = true
try {
let result
if (files && files.length > 0) {
const formData = new FormData()
formData.append('content', content)
formData.append('entityType', entityType)
formData.append('entityId', entityId)
if (entityName) formData.append('entityName', entityName)
for (const file of files) {
formData.append('files[]', file)
}
result = await postFormData('/comments', formData)
} else {
const payload: Record<string, string> = { entityType, entityId, content }
if (entityName) payload.entityName = entityName
result = await post('/comments', payload)
}
if (result.success) {
showSuccess('Commentaire ajouté')
return { success: true, data: result.data as Comment }
}
if (result.error) showError(result.error)
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
showError('Impossible d\'ajouter le commentaire')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
```
- [ ] **Step 3: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 4: Commit (in frontend submodule)**
```bash
cd Inventory_frontend
git add app/composables/useComments.ts
git commit -m "feat(comments) : support file attachments in createComment"
```
---
### Task 7: Frontend — update CommentSection.vue
**Files:**
- Modify: `Inventory_frontend/app/components/CommentSection.vue`
- [ ] **Step 1: Add file input and file list display to the template**
Replace the form section (lines 22-40) with:
```vue
<!-- 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>
```
Add after each comment's content (`<p class="text-sm whitespace-pre-wrap">`) in both open and resolved sections:
```vue
<!-- Documents attachés -->
<div v-if="comment.documents?.length" class="flex flex-wrap gap-1 mt-1">
<a
v-for="doc in comment.documents"
:key="doc.id"
:href="`${apiBase}${doc.downloadUrl}`"
target="_blank"
class="badge badge-sm badge-ghost gap-1 hover:badge-primary"
>
<IconLucideFile class="w-3 h-3" />
{{ doc.filename }}
</a>
</div>
```
- [ ] **Step 2: Update script setup**
Add new imports:
```typescript
import IconLucidePaperclip from '~icons/lucide/paperclip'
import IconLucideFile from '~icons/lucide/file'
import IconLucideX from '~icons/lucide/x'
```
Add after existing refs:
```typescript
const selectedFiles = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const apiBase = useRuntimeConfig().public.apiBase || ''
```
Add file management functions:
```typescript
const handleFilesSelected = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files) {
selectedFiles.value.push(...Array.from(input.files))
}
// Reset input so the same file can be re-selected
input.value = ''
}
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
}
```
Update `handleSubmit`:
```typescript
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()
}
}
```
- [ ] **Step 3: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 4: Commit (in frontend submodule)**
```bash
cd Inventory_frontend
git add app/components/CommentSection.vue
git commit -m "feat(comments) : add file attachment UI to CommentSection"
```
---
### Task 8: Update API Platform filter and submodule pointer
**Files:**
- Modify: `src/Entity/Document.php` (add ExistsFilter for comment)
- [ ] **Step 1: Add comment to ExistsFilter on Document entity**
Update the `ApiFilter(ExistsFilter...)` line in `Document.php`:
```php
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product', 'comment'])]
```
- [ ] **Step 2: Run php-cs-fixer + all backend tests**
Run: `make php-cs-fixer-allow-risky && make test`
- [ ] **Step 3: Commit backend**
```bash
git add src/Entity/Document.php
git commit -m "feat(documents) : add comment ExistsFilter"
```
- [ ] **Step 4: Update submodule pointer**
```bash
git add Inventory_frontend
git commit -m "chore(submodule) : update frontend pointer (comment documents feature)"
```
---
### Task 9: Manual verification
- [ ] **Step 1: Start the app**
Run: `make start`
- [ ] **Step 2: Test creating a comment without files** — should work exactly as before, response now includes `"documents": []`
- [ ] **Step 3: Test creating a comment with files** — use the paperclip button, select 1-2 files, submit. Files should appear as badges on the comment.
- [ ] **Step 4: Click a file badge** — should download the file.
- [ ] **Step 5: Run full test suite one last time**
Run: `make test`

View File

@@ -0,0 +1,809 @@
# Document Types 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:** Add a `type` enum field to documents (documentation, devis, facture, plan, photo, autre) with classification at upload and inline editing afterward.
**Architecture:** New PHP enum `DocumentType` + column on `documents` table. Migration classifies existing rows by mimeType. Frontend gets a type select at upload, a badge in document lists, and a mini-modal for editing name+type via PATCH.
**Tech Stack:** Symfony 8, API Platform, Doctrine, PHP 8.4 enums, Nuxt 4, Vue 3, DaisyUI 5
---
## File Structure
### Backend (create)
- `src/Enum/DocumentType.php` — PHP backed enum with 6 values
- `migrations/VersionXXX_add_document_type.php` — ALTER TABLE + data classification
### Backend (modify)
- `src/Entity/Document.php` — add `type` column + Patch operation
- `src/State/DocumentUploadProcessor.php` — accept `type` from FormData
- `src/Controller/DocumentQueryController.php` — add `type` to `normalizeDocuments()`
### Frontend (create)
- `Inventory_frontend/app/shared/documentTypes.ts` — type constants + labels
- `Inventory_frontend/app/components/DocumentEditModal.vue` — mini-modal for editing name+type
### Frontend (modify)
- `Inventory_frontend/app/composables/useDocuments.ts` — add `type` to interface + `updateDocument()` method
- `Inventory_frontend/app/components/DocumentUpload.vue` — add type select
- `Inventory_frontend/app/components/common/DocumentListInline.vue` — add type badge + edit button
- `Inventory_frontend/app/composables/useEntityDocuments.ts` — add `updateDocument` delegation
- `Inventory_frontend/app/pages/documents.vue` — add type column + edit button
---
### Task 1: PHP Enum + Entity Column
**Files:**
- Create: `src/Enum/DocumentType.php`
- Modify: `src/Entity/Document.php:31-54` (API resource), `src/Entity/Document.php:107-113` (add column after site)
- [ ] **Step 1: Create the DocumentType enum**
```php
// src/Enum/DocumentType.php
<?php
declare(strict_types=1);
namespace App\Enum;
enum DocumentType: string
{
case DOCUMENTATION = 'documentation';
case DEVIS = 'devis';
case FACTURE = 'facture';
case PLAN = 'plan';
case PHOTO = 'photo';
case AUTRE = 'autre';
}
```
- [ ] **Step 2: Add type column to Document entity**
In `src/Entity/Document.php`, add after the `$site` property (line ~106):
```php
#[ORM\Column(type: Types::STRING, length: 20, enumType: DocumentType::class)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private DocumentType $type = DocumentType::DOCUMENTATION;
```
Add getter/setter:
```php
public function getType(): DocumentType
{
return $this->type;
}
public function setType(DocumentType $type): static
{
$this->type = $type;
return $this;
}
```
Add the import at top: `use App\Enum\DocumentType;`
- [ ] **Step 3: Add Patch operation to Document API resource**
In the `operations` array of `#[ApiResource(...)]`, add after the existing `Put`:
```php
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
```
Add the import: `use ApiPlatform\Metadata\Patch;`
- [ ] **Step 4: Run cs-fixer and verify**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 5: Commit**
```bash
git add src/Enum/DocumentType.php src/Entity/Document.php
git commit -m "feat(documents) : add DocumentType enum and type column on entity"
```
---
### Task 2: Migration
**Files:**
- Create: new migration file via Doctrine
- [ ] **Step 1: Generate migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff`
This will generate a migration. Then edit it to add the data classification.
- [ ] **Step 2: Edit migration to classify existing documents**
The generated migration will have the `ALTER TABLE` for adding the column. After the column add, append:
```sql
UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%';
UPDATE documents SET type = 'autre' WHERE type = 'documentation' AND mimetype NOT LIKE 'application/pdf' AND mimetype NOT LIKE 'image/%';
```
Use `IF NOT EXISTS` pattern consistent with other migrations:
```php
public function up(Schema $schema): void
{
$this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'type') THEN ALTER TABLE documents ADD COLUMN type VARCHAR(20) NOT NULL DEFAULT 'documentation'; END IF; END $$");
$this->addSql("UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%'");
$this->addSql("UPDATE documents SET type = 'autre' WHERE type = 'documentation' AND mimetype NOT LIKE 'application/pdf' AND mimetype NOT LIKE 'image/%'");
}
```
- [ ] **Step 3: Run migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction`
- [ ] **Step 4: Verify data classification**
Run: `docker exec -u www-data php-inventory-apache php bin/console dbal:run-sql "SELECT type, COUNT(*) FROM documents GROUP BY type"`
- [ ] **Step 5: Commit**
```bash
git add migrations/
git commit -m "feat(documents) : add migration for type column with data classification"
```
---
### Task 3: Backend — Upload Processor + Query Controller
**Files:**
- Modify: `src/State/DocumentUploadProcessor.php:66-77`
- Modify: `src/Controller/DocumentQueryController.php:110-127`
- [ ] **Step 1: Accept type in DocumentUploadProcessor**
In `handleMultipartUpload()`, after `$document->setSize((int) $size);` (line ~77), add:
```php
// Document type from form field (default: documentation)
$typeValue = $request->request->get('type', 'documentation');
$docType = DocumentType::tryFrom($typeValue) ?? DocumentType::DOCUMENTATION;
$document->setType($docType);
```
Add import: `use App\Enum\DocumentType;`
- [ ] **Step 2: Add type to DocumentQueryController normalizeDocuments**
In `normalizeDocuments()`, add `'type'` to the returned array after `'productId'`:
```php
'type' => $document->getType()->value,
```
- [ ] **Step 3: Write test for PATCH type update**
In `tests/Api/Entity/DocumentTest.php`, add:
```php
public function testPatchType(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('documents', $doc->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['type' => 'devis'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['type' => 'devis']);
}
public function testPatchNameAndType(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('documents', $doc->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'new-name', 'type' => 'facture'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'new-name', 'type' => 'facture']);
}
public function testGetItemIncludesType(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createViewerClient();
$client->request('GET', self::iri('documents', $doc->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['type' => 'documentation']);
}
public function testViewerCannotPatch(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createViewerClient();
$client->request('PATCH', self::iri('documents', $doc->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['type' => 'devis'],
]);
$this->assertResponseStatusCodeSame(403);
}
```
- [ ] **Step 4: Run tests**
Run: `make test FILES=tests/Api/Entity/DocumentTest.php`
Expected: all tests pass
- [ ] **Step 5: Run cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/State/DocumentUploadProcessor.php src/Controller/DocumentQueryController.php tests/Api/Entity/DocumentTest.php
git commit -m "feat(documents) : accept type on upload + expose in query controller + PATCH support"
```
---
### Task 4: Frontend — Type Constants + Document Interface
**Files:**
- Create: `Inventory_frontend/app/shared/documentTypes.ts`
- Modify: `Inventory_frontend/app/composables/useDocuments.ts:6-27` (Document interface), `useDocuments.ts:205-253` (upload)
- [ ] **Step 1: Create documentTypes.ts**
```typescript
// Inventory_frontend/app/shared/documentTypes.ts
export const DOCUMENT_TYPES = [
{ value: 'documentation', label: 'Documentation' },
{ value: 'devis', label: 'Devis' },
{ value: 'facture', label: 'Facture' },
{ value: 'plan', label: 'Plan' },
{ value: 'photo', label: 'Photo' },
{ value: 'autre', label: 'Autre' },
] as const
export type DocumentTypeValue = (typeof DOCUMENT_TYPES)[number]['value']
export const getDocumentTypeLabel = (value: string): string => {
const found = DOCUMENT_TYPES.find((t) => t.value === value)
return found?.label ?? value
}
```
- [ ] **Step 2: Add type to Document interface and UploadContext**
In `useDocuments.ts`, add to `Document` interface after `downloadUrl`:
```typescript
type?: string
```
Add to `UploadContext` interface:
```typescript
type?: string
```
- [ ] **Step 3: Add type to uploadDocuments FormData**
In `uploadDocuments()`, after `formData.append('name', file.name)` (line ~220), add:
```typescript
if (context.type) formData.append('type', context.type)
```
- [ ] **Step 4: Add updateDocument method**
In `useDocuments()`, before the `return` block, add:
```typescript
const updateDocument = async (
id: string,
data: { name?: string; type?: string },
): Promise<DocumentResult> => {
loading.value = true
try {
const result = await patch(`/documents/${id}`, data)
if (result.success && result.data) {
const updated = result.data as Document
const index = documents.value.findIndex((doc) => doc.id === id)
if (index !== -1) {
documents.value[index] = { ...documents.value[index], ...updated }
}
showSuccess('Document mis à jour')
return { success: true, data: updated }
}
if (result.error) showError(result.error)
return result as DocumentResult
} catch (error) {
const err = error as Error
showError('Impossible de mettre à jour le document')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
```
Add `patch` to the destructured `useApi()` call at the top of the composable:
```typescript
const { get, patch, postFormData, delete: del } = useApi()
```
Add `updateDocument` to the return object.
- [ ] **Step 5: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 6: Commit frontend**
```bash
cd Inventory_frontend
git add app/shared/documentTypes.ts app/composables/useDocuments.ts
git commit -m "feat(documents) : add document type constants and updateDocument method"
```
---
### Task 5: Frontend — DocumentUpload Type Select
**Files:**
- Modify: `Inventory_frontend/app/components/DocumentUpload.vue`
- [ ] **Step 1: Add type prop and select to DocumentUpload**
Add prop:
```javascript
documentType: {
type: String,
default: 'documentation'
}
```
Add emit:
```javascript
'update:documentType'
```
Add a select dropdown in the template, before the file list (`<ul>`), after the button area:
```html
<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 as HTMLSelectElement).value)"
>
<option v-for="t in documentTypes" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</div>
```
Import the types:
```javascript
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
const documentTypes = DOCUMENT_TYPES
```
Note: since DocumentUpload uses `<script setup>` without `lang="ts"`, use `@change="$emit('update:documentType', $event.target.value)"` (no cast).
- [ ] **Step 2: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 3: Commit**
```bash
cd Inventory_frontend
git add app/components/DocumentUpload.vue
git commit -m "feat(documents) : add type select to DocumentUpload component"
```
---
### Task 6: Frontend — DocumentEditModal
**Files:**
- Create: `Inventory_frontend/app/components/DocumentEditModal.vue`
- [ ] **Step 1: Create DocumentEditModal component**
```vue
<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>
```
- [ ] **Step 2: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 3: Commit**
```bash
cd Inventory_frontend
git add app/components/DocumentEditModal.vue
git commit -m "feat(documents) : add DocumentEditModal component"
```
---
### Task 7: Frontend — DocumentListInline + Type Badge + Edit Button
**Files:**
- Modify: `Inventory_frontend/app/components/common/DocumentListInline.vue`
- Modify: `Inventory_frontend/app/composables/useEntityDocuments.ts`
- [ ] **Step 1: Add type badge and edit button to DocumentListInline**
In the template, after the document name `<div>` (line ~33-40), add a badge for the type:
```html
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
```
In the actions area (line ~42-68), add an edit button before "Consulter":
```html
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
title="Modifier"
@click="$emit('edit', document)"
>
Modifier
</button>
```
Add props:
```typescript
canEdit?: boolean
```
Default: `false`
Add emit:
```typescript
(e: 'edit', document: Document): void
```
Add import:
```typescript
import { getDocumentTypeLabel } from '~/shared/documentTypes'
```
- [ ] **Step 2: Add updateDocument to useEntityDocuments**
In `useEntityDocuments.ts`, add `updateDocument` from useDocuments:
```typescript
const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
```
Add method:
```typescript
const editDocument = async (id: string, data: { name?: string; type?: string }) => {
const result: any = await updateDocument(id, data)
if (result.success) {
const e = entity()
const docs = e.documents || []
const index = docs.findIndex((doc: any) => doc.id === id)
if (index !== -1) {
docs[index] = { ...docs[index], ...data }
}
}
return result
}
```
Add `editDocument` to the return object.
- [ ] **Step 3: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 4: Commit**
```bash
cd Inventory_frontend
git add app/components/common/DocumentListInline.vue app/composables/useEntityDocuments.ts
git commit -m "feat(documents) : add type badge and edit button to DocumentListInline"
```
---
### Task 8: Frontend — Wire Edit Modal in Entity Pages
**Files:**
- Modify: `Inventory_frontend/app/components/ComponentItem.vue`
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
- Modify: `Inventory_frontend/app/pages/pieces/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue`
- [ ] **Step 1: Wire in ComponentItem and PieceItem**
For each of `ComponentItem.vue` and `PieceItem.vue`:
1. Add `editDocument` from the `useEntityDocuments` return
2. Add state refs for the edit modal:
```typescript
const editingDocument = ref<any>(null)
const editModalVisible = ref(false)
```
3. Add handler:
```typescript
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name: string; type: string }) => {
if (!editingDocument.value?.id) return
await editDocument(editingDocument.value.id, data)
editModalVisible.value = false
editingDocument.value = null
}
```
4. Add `DocumentEditModal` in the template
5. Pass `:can-edit="isEditMode"` and `@edit="openEditModal"` to `DocumentListInline`
- [ ] **Step 2: Wire in edit pages (pieces/edit, component/edit, product/edit)**
Same pattern: add edit modal state, wire `DocumentListInline` with `:can-edit` and `@edit`, add `DocumentEditModal`.
- [ ] **Step 3: Wire type select in upload**
In pages that use `DocumentUpload`, add a `documentType` ref and pass it:
```html
<DocumentUpload
v-model="selectedFiles"
v-model:document-type="uploadDocType"
...
/>
```
Pass `type: uploadDocType.value` in the upload context when calling `handleFilesAdded` or `uploadDocuments`.
- [ ] **Step 4: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend
git add app/components/ app/pages/
git commit -m "feat(documents) : wire DocumentEditModal and type select in all entity pages"
```
---
### Task 9: Frontend — Documents Global Page
**Files:**
- Modify: `Inventory_frontend/app/pages/documents.vue`
- [ ] **Step 1: Add type column to DataTable**
In the `columns` array, add after `mimeType`:
```typescript
{ key: 'type', label: 'Type' },
```
Add the cell template:
```html
<template #cell-type="{ row }">
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(row.type || 'documentation') }}</span>
</template>
```
- [ ] **Step 2: Add edit button + modal**
Add an edit button in the `#cell-actions` template slot:
```html
<button
v-if="canEdit"
class="btn btn-ghost btn-xs"
type="button"
@click="openEditModal(row)"
>
Modifier
</button>
```
Add `DocumentEditModal` component in the template. Add the edit state + handler logic (same pattern as Task 8). Use `useDocuments().updateDocument` directly.
Import `usePermissions` to derive `canEdit` from the user's role (ROLE_GESTIONNAIRE or above).
- [ ] **Step 3: Add type filter**
Add a type filter select next to the existing "Rattachement" filter:
```html
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="doc-type-filter">
Type
</label>
<select
id="doc-type-filter"
v-model="typeFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="all">Tous</option>
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
```
Pass `typeFilter` to `fetchDocuments` → `loadDocuments` as a new filter param, and in `useDocuments.loadDocuments` add `params.set('type', typeFilter)` when not `'all'`.
- [ ] **Step 4: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend
git add app/pages/documents.vue app/composables/useDocuments.ts
git commit -m "feat(documents) : add type column, filter, and edit to documents page"
```
---
### Task 10: Final — Submodule Pointer + Verification
**Files:**
- Main repo: update submodule pointer
- [ ] **Step 1: Run full backend tests**
Run: `make test`
Expected: all tests pass
- [ ] **Step 2: Run full frontend checks**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck && npm run build`
Expected: 0 errors
- [ ] **Step 3: Manual verification**
1. Go to `/pieces/{id}/edit` — verify type badge on existing docs, edit modal works
2. Go to `/component/{id}/edit` — same verification
3. Upload a new document — verify type select appears, type is saved
4. Go to `/documents` — verify type column, filter, edit button
5. Check that existing PDFs show "Documentation", images show "Photo", others show "Autre"
- [ ] **Step 4: Commit submodule pointer**
```bash
cd /home/matthieu/dev_malio/Inventory
git add Inventory_frontend
git commit -m "chore(submodule) : update frontend pointer (document types feature)"
```

View File

@@ -0,0 +1,418 @@
# Fix Data-Loss Bugs — 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:** Fix all bugs that cause silent data loss in the composant/piece/product/skeleton/custom-fields data model.
**Architecture:** 6 independent fixes across backend (PHP) and frontend (TS). Each task is self-contained and can be committed independently. Backend fixes come first because they protect data integrity at the source.
**Tech Stack:** Symfony 8 / PHP 8.4 / PostgreSQL 16 / Nuxt 4 / Vue 3 / TypeScript
---
## File Map
| Task | Action | File |
|------|--------|------|
| T1 | Modify | `src/Controller/MachineStructureController.php:174-195` |
| T2 | Modify | `src/Controller/ComposantPieceSlotController.php:41-47` |
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:195-236` |
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:340-405` |
| T4 | Modify | `src/Controller/CustomFieldValueController.php:199-211` |
| T5 | Modify | `Inventory_frontend/app/composables/useComponentEdit.ts:398-405` |
| T5 | Modify | `Inventory_frontend/app/composables/usePieceEdit.ts:407-414` |
| T6 | Modify | `Inventory_frontend/app/composables/useComponentCreate.ts` (same pattern if present) |
---
### Task 1: Clone machine — CustomFieldValue pointe vers les CustomField de la source
**Probleme:** `cloneCustomFields` clone les `CustomField` (definitions) pour la target, mais les `CustomFieldValue` (valeurs) restent liees aux `CustomField` de la source. Supprimer la source cascade-delete les valeurs du clone.
**Files:**
- Modify: `src/Controller/MachineStructureController.php:174-195`
- Test: `tests/Api/Controller/MachineStructureControllerTest.php` (clone test existant)
- [ ] **Step 1: Write the failing test**
Dans le test de clone existant, ajouter une assertion : apres clone, verifier que chaque `CustomFieldValue` de la machine clonee pointe vers un `CustomField` dont `machineId` est l'ID de la machine clonee (pas la source).
```php
// After clone, fetch the cloned machine's custom field values
$clonedValues = $em->getRepository(CustomFieldValue::class)->findBy(['machine' => $clonedMachine]);
foreach ($clonedValues as $cfv) {
$this->assertSame(
$clonedMachine->getId(),
$cfv->getCustomField()->getMachine()->getId(),
'Cloned CustomFieldValue must reference the cloned CustomField, not the source'
);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `make test FILES=tests/Api/Controller/MachineStructureControllerTest.php`
Expected: FAIL — cloned values reference source machine's custom fields
- [ ] **Step 3: Implement the fix**
In `cloneCustomFields`, build a map `$oldCfId => $newCf` in the first loop, then use it in the second loop:
```php
private function cloneCustomFields(Machine $source, Machine $target): void
{
$cfMap = [];
foreach ($source->getCustomFields() as $cf) {
$newCf = new CustomField();
$newCf->setName($cf->getName());
$newCf->setType($cf->getType());
$newCf->setRequired($cf->isRequired());
$newCf->setDefaultValue($cf->getDefaultValue());
$newCf->setOptions($cf->getOptions());
$newCf->setOrderIndex($cf->getOrderIndex());
$newCf->setMachine($target);
$this->entityManager->persist($newCf);
$cfMap[$cf->getId()] = $newCf;
}
foreach ($source->getCustomFieldValues() as $cfv) {
$originalCf = $cfv->getCustomField();
$newCf = $cfMap[$originalCf->getId()] ?? null;
if (!$newCf) {
continue;
}
$newValue = new CustomFieldValue();
$newValue->setMachine($target);
$newValue->setCustomField($newCf);
$newValue->setValue($cfv->getValue());
$this->entityManager->persist($newValue);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `make test FILES=tests/Api/Controller/MachineStructureControllerTest.php`
Expected: PASS
- [ ] **Step 5: Lint**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/Controller/MachineStructureController.php tests/Api/Controller/MachineStructureControllerTest.php
git commit -m "fix(clone) : custom field values reference cloned definitions, not source"
```
---
### Task 2: ComposantPieceSlot PATCH — pas de validation du type de piece ni 404
**Probleme:** On peut assigner n'importe quelle piece dans un slot, meme si son type ne correspond pas au type requis par le squelette. Si la piece n'existe pas, `null` est silencieusement mis.
**Files:**
- Modify: `src/Controller/ComposantPieceSlotController.php:41-47`
- Test: `tests/Api/Controller/ComposantPieceSlotControllerTest.php` (creer si absent)
- [ ] **Step 1: Write the failing test — piece not found returns 404**
```php
public function testPatchSlotWithNonExistentPieceReturns404(): void
{
$client = $this->createGestionnaireClient();
// Create a slot via fixtures
$slot = $this->createComposantPieceSlot();
$client->request('PATCH', '/api/composant-piece-slots/' . $slot->getId(), [
'json' => ['selectedPieceId' => 'cl_nonexistent_id'],
'headers' => ['Content-Type' => 'application/json'],
]);
$this->assertResponseStatusCodeSame(404);
}
```
- [ ] **Step 2: Write the failing test — wrong piece type returns 422**
```php
public function testPatchSlotWithWrongPieceTypeReturns422(): void
{
$client = $this->createGestionnaireClient();
$typeA = $this->createModelType(['category' => 'piece', 'name' => 'Type A']);
$typeB = $this->createModelType(['category' => 'piece', 'name' => 'Type B']);
$slot = $this->createComposantPieceSlot(['typePiece' => $typeA]);
$wrongPiece = $this->createPiece(['typePiece' => $typeB]);
$client->request('PATCH', '/api/composant-piece-slots/' . $slot->getId(), [
'json' => ['selectedPieceId' => $wrongPiece->getId()],
'headers' => ['Content-Type' => 'application/json'],
]);
$this->assertResponseStatusCodeSame(422);
}
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `make test FILES=tests/Api/Controller/ComposantPieceSlotControllerTest.php`
Expected: FAIL
- [ ] **Step 4: Implement the fix**
```php
if (array_key_exists('selectedPieceId', $payload)) {
if (null === $payload['selectedPieceId']) {
$slot->setSelectedPiece(null);
} else {
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
if (!$piece) {
return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
}
$slotTypePiece = $slot->getTypePiece();
if ($slotTypePiece && $piece->getTypePiece()?->getId() !== $slotTypePiece->getId()) {
return $this->json([
'success' => false,
'error' => sprintf(
'La pièce doit être de type « %s ».',
$slotTypePiece->getName(),
),
], 422);
}
$slot->setSelectedPiece($piece);
}
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `make test FILES=tests/Api/Controller/ComposantPieceSlotControllerTest.php`
Expected: PASS
- [ ] **Step 6: Lint + commit**
```bash
make php-cs-fixer-allow-risky
git add src/Controller/ComposantPieceSlotController.php tests/Api/Controller/ComposantPieceSlotControllerTest.php
git commit -m "fix(slots) : validate piece type matches slot requirement + 404 on missing piece"
```
---
### Task 3: Conversion de categorie — slots supprimes sans verification + skeleton requirements orphelins
**Probleme A:** `checkComponentToPiece` verifie `structure IS NOT NULL` (ancien JSON) mais les donnees sont dans les tables de slots. Le check passe toujours et les slots sont cascade-deleted.
**Probleme B:** Apres conversion, les `skeleton_piece_requirements`, `skeleton_product_requirements`, `skeleton_subcomponent_requirements` de l'ancien type ne sont pas supprimes.
**Files:**
- Modify: `src/Service/ModelTypeCategoryConversionService.php:195-236` (check)
- Modify: `src/Service/ModelTypeCategoryConversionService.php:340-405` (convert)
- [ ] **Step 1: Fix `checkComponentToPiece` — ajouter le check sur les tables de slots**
Apres le check `structure IS NOT NULL` existant (qui reste pour compatibilite), ajouter :
```php
// Check slot tables for actual data (post-normalization architecture)
$slotsWithData = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_piece_slots cps
JOIN composants c ON cps.composantid = c.id
WHERE c.typecomposantid = :id AND cps.selectedpieceid IS NOT NULL',
['id' => $modelTypeId],
);
$subSlots = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_subcomponent_slots css
JOIN composants c ON css.composantid = c.id
WHERE c.typecomposantid = :id AND css.selectedcomposantid IS NOT NULL',
['id' => $modelTypeId],
);
if ($slotsWithData > 0 || $subSlots > 0) {
$parts = [];
if ($slotsWithData > 0) {
$parts[] = sprintf('%d slot(s) pièce rempli(s)', $slotsWithData);
}
if ($subSlots > 0) {
$parts[] = sprintf('%d slot(s) sous-composant rempli(s)', $subSlots);
}
$blockers[] = sprintf(
'Des composants ont des données dans leurs slots : %s.',
implode(', ', $parts),
);
}
```
- [ ] **Step 2: Fix `convertComponentToPiece` — nettoyer les skeleton requirements avant le changement de categorie**
Ajouter entre l'etape 6 (DELETE composants) et l'etape 7 (UPDATE model_types) :
```php
// 6b. Clean up skeleton requirements that belong to COMPONENT category
$this->connection->executeStatement(
'DELETE FROM skeleton_piece_requirements WHERE modeltypeid = :id',
['id' => $modelTypeId],
);
$this->connection->executeStatement(
'DELETE FROM skeleton_subcomponent_requirements WHERE modeltypeid = :id',
['id' => $modelTypeId],
);
// Note: skeleton_product_requirements are kept — valid for both COMPONENT and PIECE categories
```
- [ ] **Step 3: Fix `convertPieceToComponent` — meme nettoyage dans l'autre sens**
Les `skeleton_product_requirements` qui appartenaient au type PIECE restent. Aucun nettoyage specifique necessaire car les product requirements sont valides pour les deux types. Mais verifier que la methode existe et n'a pas le meme probleme.
- [ ] **Step 4: Run all conversion tests**
Run: `make test FILES=tests/Api/Controller/ModelTypeConversionControllerTest.php`
Si absent: `make test` (tous les tests)
Expected: PASS
- [ ] **Step 5: Lint + commit**
```bash
make php-cs-fixer-allow-risky
git add src/Service/ModelTypeCategoryConversionService.php
git commit -m "fix(conversion) : block conversion when slots have data + clean skeleton requirements"
```
---
### Task 4: CustomFieldValueController — cree des CustomField orphelins sans FK
**Probleme:** Quand `customFieldId` est absent et `customFieldName` est fourni, un nouveau `CustomField` est cree sans etre rattache a aucune entite (ni machine, ni modelType). La ligne est invisible et inutile.
**Files:**
- Modify: `src/Controller/CustomFieldValueController.php:199-211`
- [ ] **Step 1: Implement the fix**
La methode `resolveCustomField` cree un `CustomField` orphelin. Il faut utiliser le `target` (deja resolu) pour rattacher le champ au bon parent. Le plus simple : deplacer la creation du CustomField apres la resolution du target, ou passer le target en parametre.
Option retenue : retourner un array `['customField' => $cf, 'isNew' => true]` et laisser `applyTarget` gerer le rattachement, OU plus simplement, interdire la creation ad-hoc et retourner une erreur 400 quand le champ n'existe pas.
L'approche la plus sure (pas de CustomField orphelin) :
```php
// In resolveCustomField, replace the auto-creation block with:
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
if ('' === $customFieldName) {
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
}
// Try to find existing custom field by name for the target entity
$target = $this->resolveTarget($payload);
if ($target instanceof JsonResponse) {
return $this->json(['success' => false, 'error' => 'Cannot create custom field without a valid target entity.'], 400);
}
$existingField = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
if ($existingField) {
return $existingField;
}
return $this->json(['success' => false, 'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName)], 404);
```
**Alternative plus conservative** si le frontend depend de cette auto-creation : garder la creation mais rattacher au target. Cela necessite de refactorer le flow pour passer le target a `resolveCustomField`. Choisir selon le frontend.
- [ ] **Step 2: Run tests**
Run: `make test`
Expected: PASS (verifier qu'aucun test ne depend de l'auto-creation)
- [ ] **Step 3: Lint + commit**
```bash
make php-cs-fixer-allow-risky
git add src/Controller/CustomFieldValueController.php
git commit -m "fix(custom-fields) : prevent creation of orphan CustomField without target entity"
```
---
### Task 5: Frontend — custom fields definition lookup au mauvais chemin
**Probleme:** `useComponentEdit` passe `typeComposant.customFields` (pas serialise par l'API) au lieu de `typeComposant.structure.customFields`. Idem `usePieceEdit` avec `typePiece.pieceCustomFields` au lieu de `typePiece.structure.customFields`.
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:**
- Modify: `Inventory_frontend/app/composables/useComponentEdit.ts:401-403`
- Modify: `Inventory_frontend/app/composables/usePieceEdit.ts:410-412`
- [ ] **Step 1: Fix useComponentEdit.ts**
Ligne 401-403, remplacer :
```ts
[
updatedComponent?.typeComposant?.customFields,
]
```
par :
```ts
[
updatedComponent?.typeComposant?.structure?.customFields,
]
```
- [ ] **Step 2: Fix usePieceEdit.ts**
Ligne 410-412, remplacer :
```ts
[
updatedPiece?.typePiece?.pieceCustomFields,
]
```
par :
```ts
[
updatedPiece?.typePiece?.structure?.customFields,
]
```
- [ ] **Step 3: Verifier le meme pattern dans les autres fichiers**
Verifier `useComponentCreate.ts`, `pieces/create.vue`, `product/[id]/edit.vue` pour le meme probleme.
- [ ] **Step 4: Lint + typecheck**
```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend
git add app/composables/useComponentEdit.ts app/composables/usePieceEdit.ts
git commit -m "fix(custom-fields) : use structure.customFields path for definition lookup"
```
---
### Task 6 (bonus): Verifier et corriger les memes patterns dans create flows
- [ ] **Step 1:** Grep `_saveCustomFieldValues` dans tous les fichiers et verifier que chaque appel passe `structure.customFields` et non `customFields` ou `pieceCustomFields` directement.
- [ ] **Step 2:** Corriger si necessaire, lint, commit.
---
## Ordre d'execution recommande
1. **T1** (clone) — fix isole, pas de dependance
2. **T2** (slots validation) — fix isole
3. **T5** (frontend custom fields path) — fix isole
4. **T4** (orphan CustomField) — depend de T5 pour comprendre si le frontend utilise l'auto-creation
5. **T3** (conversion) — le plus complexe, faire en dernier
6. **T6** (bonus verification)

View File

@@ -0,0 +1,409 @@
# Parc Machines UX Improvements — 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:** Multi-select site filter with checkboxes, alphabetical sorting on Parc Machines, and OR search (name/reference) on catalog pages.
**Architecture:** Frontend-only changes for tasks 1-2 (Vue reactivity + computed sort). Backend Doctrine Extension for task 3 that intercepts `?q=` parameter and builds an OR clause across `name` and `reference` fields, with corresponding frontend composable changes.
**Tech Stack:** Vue 3 (reactive Set), DaisyUI 5 checkboxes, Symfony/API Platform Doctrine ORM Extension, PHPUnit
**Spec:** `docs/superpowers/specs/2026-03-23-parc-machines-ux-design.md`
---
### Task 1: Multi-select site checkboxes on Parc Machines
**Files:**
- Modify: `Inventory_frontend/app/pages/machines/index.vue`
- [ ] **Step 1: Replace `selectedSite` ref with reactive Set**
In `<script setup>`, replace:
```js
const selectedSite = ref('')
```
with:
```js
const selectedSites = reactive(new Set())
```
- [ ] **Step 2: Replace `<select>` with checkboxes in template**
Replace the site filter `<div class="form-control">` block (the one containing the `<select>`) with:
```vue
<div class="form-control">
<label class="label">
<span class="label-text">Sites</span>
</label>
<div class="flex flex-wrap gap-3">
<label
v-for="site in sites"
:key="site.id"
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="selectedSites.has(site.id)"
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
>
<span class="text-sm">{{ site.name }}</span>
</label>
</div>
</div>
```
- [ ] **Step 3: Update `filteredMachines` computed for multi-select**
Replace:
```js
if (selectedSite.value) {
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
}
```
with:
```js
if (selectedSites.size > 0) {
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
}
```
- [ ] **Step 4: Clean up unused `ref` import if needed**
Check if `ref` is still used elsewhere in the file (it is — `searchQuery` uses it). If so, keep it. Remove only if no longer referenced.
- [ ] **Step 5: Add `reactive` to imports**
Add `reactive` to the import from `vue`:
```js
import { ref, reactive, computed, onMounted } from 'vue'
```
- [ ] **Step 6: Verify in browser**
Open `http://localhost:3001/machines`. Confirm:
- Checkboxes appear for each site
- Checking one site filters machines to that site only
- Checking multiple sites shows machines from all selected sites
- Unchecking all shows all machines
- [ ] **Step 7: Run frontend lint**
Run: `cd Inventory_frontend && npm run lint:fix`
---
### Task 2: Alphabetical sorting on Parc Machines
**Files:**
- Modify: `Inventory_frontend/app/pages/machines/index.vue`
- [ ] **Step 1: Add sort to `filteredMachines` computed**
At the end of the `filteredMachines` computed, just before `return filtered`, add:
```js
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
```
The full computed should now be:
```js
const filteredMachines = computed(() => {
let filtered = enrichedMachines.value
if (selectedSites.size > 0) {
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
}
if (searchQuery.value.trim()) {
const term = searchQuery.value.trim().toLowerCase()
filtered = filtered.filter(machine =>
machine.name?.toLowerCase().includes(term)
|| machine.reference?.toLowerCase().includes(term),
)
}
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
return filtered
})
```
- [ ] **Step 2: Verify in browser**
Open `http://localhost:3001/machines`. Confirm machines are sorted A→Z by name. Test with site filter active — should still be sorted.
- [ ] **Step 3: Commit Tasks 1 + 2**
```bash
cd Inventory_frontend && git add app/pages/machines/index.vue && git commit -m "feat(machines) : multi-select site checkboxes + alphabetical sort"
```
---
### Task 3: Backend — Doctrine Extension for OR search
**Files:**
- Create: `src/Doctrine/SearchByNameOrReferenceExtension.php`
- [ ] **Step 1: Add `reference` parameter to `createComposant` factory**
In `tests/AbstractApiTestCase.php`, update the `createComposant` method to accept an optional `$reference` parameter:
Find:
```php
protected function createComposant(string $name = 'Composant Test', ?ModelType $type = null): Composant
{
$c = new Composant();
$c->setName($name);
if (null !== $type) {
$c->setTypeComposant($type);
}
```
Replace with:
```php
protected function createComposant(string $name = 'Composant Test', ?string $reference = null, ?ModelType $type = null): Composant
{
$c = new Composant();
$c->setName($name);
if (null !== $reference) {
$c->setReference($reference);
}
if (null !== $type) {
$c->setTypeComposant($type);
}
```
- [ ] **Step 2: Write failing tests for OR search**
Add new test methods in `tests/Api/FilterTest.php`:
```php
public function testOrSearchByNameOnPieces(): void
{
$this->createPiece('Joint torique', 'REF-JT-001');
$this->createPiece('Roulement', 'REF-RL-002');
$client = $this->createViewerClient();
$client->request('GET', '/api/pieces?q=joint');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testOrSearchByReferenceOnPieces(): void
{
$this->createPiece('Joint torique', 'REF-JT-001');
$this->createPiece('Roulement', 'REF-RL-002');
$client = $this->createViewerClient();
$client->request('GET', '/api/pieces?q=RL-002');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testOrSearchMatchesBothNameAndReference(): void
{
$this->createComposant('Pompe REF-X', 'REF-POMPE-01');
$this->createComposant('Vanne', 'REF-VANNE-01');
$this->createComposant('Moteur', 'POMPE-MOTEUR');
$client = $this->createViewerClient();
$client->request('GET', '/api/composants?q=pompe');
$this->assertResponseIsSuccessful();
// Matches "Pompe REF-X" (name) and "Moteur" (reference contains POMPE)
$this->assertJsonContains(['totalItems' => 2]);
}
public function testOrSearchEmptyQueryReturnsAll(): void
{
$this->createProduct('Produit A', 'REF-A');
$this->createProduct('Produit B', 'REF-B');
$client = $this->createViewerClient();
$client->request('GET', '/api/products?q=');
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertGreaterThanOrEqual(2, $data['totalItems']);
}
public function testOrSearchOnProducts(): void
{
$this->createProduct('Huile moteur', 'HM-500');
$this->createProduct('Graisse', 'GR-100');
$client = $this->createViewerClient();
$client->request('GET', '/api/products?q=HM-500');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `make test FILES=tests/Api/FilterTest.php`
Expected: New tests fail (the `q` parameter is not handled yet).
- [ ] **Step 4: Create the Doctrine Extension**
Create `src/Doctrine/SearchByNameOrReferenceExtension.php`:
```php
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Composant;
use App\Entity\Piece;
use App\Entity\Product;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
final class SearchByNameOrReferenceExtension implements QueryCollectionExtensionInterface
{
private const SUPPORTED_CLASSES = [
Piece::class,
Composant::class,
Product::class,
];
public function __construct(
private readonly RequestStack $requestStack,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (!\in_array($resourceClass, self::SUPPORTED_CLASSES, true)) {
return;
}
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return;
}
$q = $request->query->get('q', '');
if (!\is_string($q) || '' === trim($q)) {
return;
}
$escaped = addcslashes(trim($q), '%_');
$paramName = $queryNameGenerator->generateParameterName('searchQ');
$alias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->andWhere(sprintf('LOWER(%s.name) LIKE :%s OR LOWER(%s.reference) LIKE :%s', $alias, $paramName, $alias, $paramName))
->setParameter($paramName, '%' . strtolower($escaped) . '%');
}
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `make test FILES=tests/Api/FilterTest.php`
Expected: All tests pass, including the new OR search tests.
- [ ] **Step 6: Run full test suite**
Run: `make test`
Expected: All tests pass (no regressions).
- [ ] **Step 7: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 8: Commit backend changes**
```bash
git add src/Doctrine/SearchByNameOrReferenceExtension.php tests/Api/FilterTest.php tests/AbstractApiTestCase.php && git commit -m "feat(search) : OR search extension for name/reference on Piece, Composant, Product"
```
---
### Task 4: Frontend — Switch composables from `name` to `q`
**Files:**
- Modify: `Inventory_frontend/app/composables/usePieces.ts`
- Modify: `Inventory_frontend/app/composables/useComposants.ts`
- Modify: `Inventory_frontend/app/composables/useProducts.ts`
- [ ] **Step 1: Update `usePieces.ts`**
In the `loadPieces` function, replace:
```ts
if (search && search.trim()) {
params.set('name', search.trim())
}
```
with:
```ts
if (search && search.trim()) {
params.set('q', search.trim())
}
```
- [ ] **Step 2: Update `useComposants.ts`**
Same change in the `loadComposants` function:
```ts
params.set('name', search.trim())
```
```ts
params.set('q', search.trim())
```
- [ ] **Step 3: Update `useProducts.ts`**
Same change in the `loadProducts` function:
```ts
params.set('name', search.trim())
```
```ts
params.set('q', search.trim())
```
- [ ] **Step 4: Run frontend lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 5: Verify in browser**
Open each catalog page and test search:
- `http://localhost:3001/pieces-catalog` — search by name, then by reference
- `http://localhost:3001/component-catalog` — search by name, then by reference
- `http://localhost:3001/product-catalog` — search by name, then by reference
Confirm that searching by a reference value returns the correct results.
- [ ] **Step 6: Commit frontend changes**
```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"
```
- [ ] **Step 7: Update submodule pointer in main repo**
```bash
cd /home/matthieu/dev_malio/Inventory && git add Inventory_frontend && git commit -m "chore(submodule) : update frontend pointer (OR search + site checkboxes)"
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,857 @@
# ReferenceAuto — Génération automatique de référence pièce
> **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:** Générer automatiquement une référence technique normalisée (`referenceAuto`) pour les pièces, basée sur une formule configurable définie au niveau du ModelType et alimentée par les CustomFieldValues de chaque Piece.
**Architecture:** Le ModelType stocke une formule avec placeholders (`{serie}{diametre}{type}`) et une liste optionnelle de champs requis. Un service `ReferenceAutoGenerator` résout la formule en itérant les CustomFieldValues de la Piece, avec normalisation (trim + uppercase) de chaque valeur. Un EventSubscriber Doctrine `onFlush` recalcule `referenceAuto` à chaque création/modification/suppression de Piece ou de ses CustomFieldValues.
**Tech Stack:** Symfony 8, Doctrine ORM (PHP 8 attributes), API Platform, PostgreSQL, PHPUnit 12
---
## Règles métier
- **referenceAuto** est un champ système **non éditable** par l'utilisateur, distinct de `reference` (saisie libre)
- La formule produit un **code technique structuré**, pas du texte lisible (ex: `2207K`, `SNU507`, `U507`)
- Les valeurs des CustomFields sont **normalisées** avant assemblage : `trim()` + `mb_strtoupper()`
- Champ requis manquant ou vide → `referenceAuto = null`
- Pas de formule sur le ModelType → `referenceAuto = null`
- Pas de ModelType sur la Piece → `referenceAuto = null`
- Le recalcul est déclenché par : création/modification/suppression de Piece, création/modification/suppression de CustomFieldValue lié à une Piece
- L'absence de formule sur un ModelType signifie implicitement que ce type n'est pas éligible à la génération
- Périmètre actuel : **Piece uniquement** (extensible à Composant/Product plus tard si besoin)
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `src/Entity/ModelType.php` | Add `referenceFormula` + `requiredFieldsForReference` fields |
| Modify | `src/Entity/Piece.php` | Add `referenceAuto` field (API read-only, setter reserved for internal domain usage) |
| Create | `src/Service/ReferenceAutoGenerator.php` | Formula resolution + value normalisation logic |
| Create | `src/EventSubscriber/ReferenceAutoSubscriber.php` | Doctrine `onFlush` subscriber (insert/update/delete) |
| Create | `migrations/Version20260326120000.php` | Add DB columns |
| Create | `tests/Service/ReferenceAutoGeneratorTest.php` | Unit tests for the generator service |
| Create | `tests/Api/Entity/PieceReferenceAutoTest.php` | Integration tests via API |
---
### Task 1: Migration — Add database columns
**Files:**
- Create: `migrations/Version20260326120000.php`
- [ ] **Step 1: Create the migration file**
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260326120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add referenceFormula and requiredFieldsForReference to model_types, referenceAuto to pieces';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS referenceformula TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS requiredfieldsforreference JSON DEFAULT NULL');
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS referenceauto VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS referenceauto');
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS requiredfieldsforreference');
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS referenceformula');
}
}
```
- [ ] **Step 2: Run the migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration applied successfully.
- [ ] **Step 3: Commit**
```bash
git add migrations/Version20260326120000.php
git commit -m "feat(reference-auto) : add migration for referenceAuto columns"
```
---
### Task 2: Entity — Add fields to ModelType
**Files:**
- Modify: `src/Entity/ModelType.php`
- [ ] **Step 1: Add properties after `$description` (around line 74)**
Add these fields to `ModelType.php`, after `$description` and before `$createdAt`:
```php
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['model_type:read', 'model_type:write'])]
private ?string $referenceFormula = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['model_type:read', 'model_type:write'])]
private ?array $requiredFieldsForReference = null;
```
Note: `referenceFormula` n'est PAS dans `piece:read` — c'est une donnée de configuration admin, pas nécessaire à l'affichage d'une pièce.
- [ ] **Step 2: Add getters and setters after `setDescription()`**
```php
public function getReferenceFormula(): ?string
{
return $this->referenceFormula;
}
public function setReferenceFormula(?string $referenceFormula): static
{
$this->referenceFormula = $referenceFormula;
return $this;
}
public function getRequiredFieldsForReference(): ?array
{
return $this->requiredFieldsForReference;
}
public function setRequiredFieldsForReference(?array $requiredFieldsForReference): static
{
$this->requiredFieldsForReference = $requiredFieldsForReference;
return $this;
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
Expected: All files fixed or already clean.
- [ ] **Step 4: Commit**
```bash
git add src/Entity/ModelType.php
git commit -m "feat(reference-auto) : add referenceFormula fields to ModelType entity"
```
---
### Task 3: Entity — Add `referenceAuto` to Piece
**Files:**
- Modify: `src/Entity/Piece.php`
- [ ] **Step 1: Add `referenceAuto` property after `$reference` (line 64)**
```php
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['piece:read'])]
private ?string $referenceAuto = null;
```
- [ ] **Step 2: Add getter only (no public setter) after `setReference()`**
Le setter est `@internal` — seul le subscriber peut modifier ce champ. On n'expose pas de setter public pour protéger le contrat d'API. Le subscriber accède directement à la propriété via un setter interne.
```php
public function getReferenceAuto(): ?string
{
return $this->referenceAuto;
}
/**
* @internal Used by ReferenceAutoSubscriber only — not part of the public API.
*/
public function setReferenceAuto(?string $referenceAuto): static
{
$this->referenceAuto = $referenceAuto;
return $this;
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
Expected: Clean.
- [ ] **Step 4: Commit**
```bash
git add src/Entity/Piece.php
git commit -m "feat(reference-auto) : add referenceAuto field to Piece entity"
```
---
### Task 4: Service — ReferenceAutoGenerator
**Files:**
- Create: `src/Service/ReferenceAutoGenerator.php`
- Create: `tests/Service/ReferenceAutoGeneratorTest.php`
- [ ] **Step 1: Write the failing test**
Create `tests/Service/ReferenceAutoGeneratorTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ReferenceAutoGeneratorTest extends AbstractApiTestCase
{
public function testGenerateWithFormula(): void
{
$mt = $this->createModelType('Roulement', 'ROUL-001', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Test', null, $mt);
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'K', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('2207K', $result);
}
public function testGenerateNormalizesValues(): void
{
$mt = $this->createModelType('Roulement Norm', 'ROUL-002', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Norm', null, $mt);
// Values with spaces and lowercase — should be trimmed and uppercased
$this->createCustomFieldValue($cfSerie, ' 22 ', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'k', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('2207K', $result);
}
public function testGenerateReturnsNullWithoutFormula(): void
{
$mt = $this->createModelType('Galet', 'GAL-001', ModelCategory::PIECE);
$piece = $this->createPiece('Galet Test', null, $mt);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateReturnsNullWhenNoModelType(): void
{
$piece = $this->createPiece('Orphan Piece');
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateReturnsNullWhenRequiredFieldsMissing(): void
{
$mt = $this->createModelType('Palier', 'PAL-001', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$piece = $this->createPiece('Palier Test', null, $mt);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateReturnsNullWhenRequiredFieldEmpty(): void
{
$mt = $this->createModelType('Palier Vide', 'PAL-003', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Palier Vide', null, $mt);
// Value is whitespace only — after trim, it's empty
$this->createCustomFieldValue($cfTaille, ' ', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateWithStaticTextInFormula(): void
{
$mt = $this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE);
$mt->setReferenceFormula('U{taille}');
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Joint Test', null, $mt);
$this->createCustomFieldValue($cfTaille, '507', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('U507', $result);
}
public function testGenerateWithSpaceInFormula(): void
{
$mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Palier Test 2', null, $mt);
$this->createCustomFieldValue($cfTaille, '507', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('SNU 507', $result);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `make test FILES=tests/Service/ReferenceAutoGeneratorTest.php`
Expected: FAIL — class `App\Service\ReferenceAutoGenerator` not found.
- [ ] **Step 3: Create the service**
Create `src/Service/ReferenceAutoGenerator.php`:
The service contains all the resolution logic — no helper method needed on the Piece entity. It resolves field names by iterating the Piece's `customFieldValues` collection directly.
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
class ReferenceAutoGenerator
{
public function generate(Piece $piece): ?string
{
$modelType = $piece->getTypePiece();
if (!$modelType || !$modelType->getReferenceFormula()) {
return null;
}
$valueMap = $this->buildValueMap($piece);
$requiredFields = $modelType->getRequiredFieldsForReference();
if ($requiredFields) {
foreach ($requiredFields as $fieldName) {
if (!isset($valueMap[$fieldName]) || '' === $valueMap[$fieldName]) {
return null;
}
}
}
return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string {
return $valueMap[$matches[1]] ?? '';
}, $modelType->getReferenceFormula());
}
/**
* Build a map of fieldName → normalized value from the Piece's CustomFieldValues.
*
* @return array<string, string>
*/
private function buildValueMap(Piece $piece): array
{
$map = [];
/** @var CustomFieldValue $cfv */
foreach ($piece->getCustomFieldValues() as $cfv) {
$normalized = mb_strtoupper(trim($cfv->getValue()));
$map[$cfv->getCustomField()->getName()] = $normalized;
}
return $map;
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `make test FILES=tests/Service/ReferenceAutoGeneratorTest.php`
Expected: All 8 tests PASS.
- [ ] **Step 5: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/Service/ReferenceAutoGenerator.php tests/Service/ReferenceAutoGeneratorTest.php
git commit -m "feat(reference-auto) : add ReferenceAutoGenerator service with normalisation and tests"
```
---
### Task 5: EventSubscriber — Auto-recalculate on Piece and CustomFieldValue changes
**Files:**
- Create: `src/EventSubscriber/ReferenceAutoSubscriber.php`
- Create: `tests/Api/Entity/PieceReferenceAutoTest.php`
**Triggers for recalculation:**
- Piece inserted or updated
- CustomFieldValue inserted, updated, or **deleted** (linked to a Piece)
- [ ] **Step 1: Write the failing integration test**
Create `tests/Api/Entity/PieceReferenceAutoTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class PieceReferenceAutoTest extends AbstractApiTestCase
{
public function testReferenceAutoGeneratedAfterAllCfvCreated(): void
{
$mt = $this->createModelType('Roulement', 'ROUL-010', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Auto', null, $mt);
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'K', piece: $piece);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => '2207K']);
}
public function testReferenceAutoNullWhenNoFormula(): void
{
$mt = $this->createModelType('Galet', 'GAL-010', ModelCategory::PIECE);
$piece = $this->createPiece('Galet Auto', null, $mt);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoNullWhenRequiredFieldsMissing(): void
{
$mt = $this->createModelType('Palier', 'PAL-010', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$piece = $this->createPiece('Palier Sans Champ', null, $mt);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoUpdatedWhenCustomFieldValueChanges(): void
{
$mt = $this->createModelType('Joint', 'JOINT-010', ModelCategory::PIECE);
$mt->setReferenceFormula('U{taille}');
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Joint Upd', null, $mt);
$cfv = $this->createCustomFieldValue($cfTaille, '507', piece: $piece);
// After creating the CFV, the subscriber should have set referenceAuto
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => 'U507']);
// Now update the CFV value via API
$gClient = $this->createGestionnaireClient();
$gClient->request('PATCH', self::iri('custom_field_values', $cfv->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['value' => '608'],
]);
$this->assertResponseIsSuccessful();
// Read piece again — referenceAuto should be updated
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertJsonContains(['referenceAuto' => 'U608']);
}
public function testReferenceAutoNullAfterRequiredCfvDeleted(): void
{
$mt = $this->createModelType('Joint Del', 'JOINT-011', ModelCategory::PIECE);
$mt->setReferenceFormula('U{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Joint Del', null, $mt);
$cfv = $this->createCustomFieldValue($cfTaille, '507', piece: $piece);
// Confirm referenceAuto is set
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertJsonContains(['referenceAuto' => 'U507']);
// Delete the CFV
$gClient = $this->createGestionnaireClient();
$gClient->request('DELETE', self::iri('custom_field_values', $cfv->getId()));
$this->assertResponseStatusCodeSame(204);
// referenceAuto should now be null (required field missing)
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoIsReadOnlyViaApi(): void
{
$piece = $this->createPiece('ReadOnly Test');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('pieces', $piece->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['referenceAuto' => 'HACKED'],
]);
$this->assertResponseIsSuccessful();
$viewer = $this->createViewerClient();
$viewer->request('GET', self::iri('pieces', $piece->getId()));
// referenceAuto should still be null (no formula), not 'HACKED'
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoNormalizesLowercaseValues(): void
{
$mt = $this->createModelType('Roulement Norm', 'ROUL-011', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Norm', null, $mt);
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'k', piece: $piece);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
// 'k' should be normalized to 'K'
$this->assertJsonContains(['referenceAuto' => '2207K']);
}
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `make test FILES=tests/Api/Entity/PieceReferenceAutoTest.php`
Expected: FAIL — referenceAuto not being set automatically.
- [ ] **Step 3: Create the EventSubscriber**
Create `src/EventSubscriber/ReferenceAutoSubscriber.php`:
```php
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Service\ReferenceAutoGenerator;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
final class ReferenceAutoSubscriber implements EventSubscriber
{
public function __construct(private readonly ReferenceAutoGenerator $generator) {}
public function getSubscribedEvents(): array
{
return [Events::onFlush];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
$piecesToRecalculate = [];
// Collect Pieces from direct insertions/updates
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof Piece) {
$piecesToRecalculate[$entity->getId()] = $entity;
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($entity instanceof Piece) {
$piecesToRecalculate[$entity->getId()] = $entity;
}
}
// Collect Pieces from CustomFieldValue insertions
// The new CFV is not yet in the DB, so Piece's lazy-loaded collection won't
// contain it. We must add it manually so the generator sees the new value.
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
$piece = $entity->getPiece();
if (!$piece->getCustomFieldValues()->contains($entity)) {
$piece->getCustomFieldValues()->add($entity);
}
$piecesToRecalculate[$piece->getId()] = $piece;
}
}
// Collect Pieces from CustomFieldValue updates
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
$piece = $entity->getPiece();
$piecesToRecalculate[$piece->getId()] = $piece;
}
}
// Collect Pieces from CustomFieldValue deletions
// When a CFV is deleted, remove it from the collection so the generator
// doesn't see the stale value. referenceAuto must revert to null if required.
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
$piece = $entity->getPiece();
$piece->getCustomFieldValues()->removeElement($entity);
$piecesToRecalculate[$piece->getId()] = $piece;
}
}
// Recalculate referenceAuto for each collected Piece
$meta = $em->getClassMetadata(Piece::class);
foreach ($piecesToRecalculate as $piece) {
$newRef = $this->generator->generate($piece);
if ($piece->getReferenceAuto() !== $newRef) {
$piece->setReferenceAuto($newRef);
$uow->recomputeSingleEntityChangeSet($meta, $piece);
}
}
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `make test FILES=tests/Api/Entity/PieceReferenceAutoTest.php`
Expected: All 7 tests PASS.
- [ ] **Step 5: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/EventSubscriber/ReferenceAutoSubscriber.php tests/Api/Entity/PieceReferenceAutoTest.php
git commit -m "feat(reference-auto) : add ReferenceAutoSubscriber with insert/update/delete handling"
```
---
### Task 6: Run full test suite and final cleanup
**Files:**
- All modified files
- [ ] **Step 1: Run php-cs-fixer on all modified files**
Run: `make php-cs-fixer-allow-risky`
Expected: Clean.
- [ ] **Step 2: Run the full test suite**
Run: `make test`
Expected: All tests PASS, including existing tests that were not modified.
- [ ] **Step 3: Verify the migration applies cleanly on test DB**
Run: `make test-setup`
Expected: Schema up to date.
- [ ] **Step 4: Final commit if any cleanup was needed**
```bash
git add -A
git commit -m "chore(reference-auto) : final cleanup and lint fixes"
```
---
## Design Notes
### Formule = code technique, pas texte libre
La formule doit produire un **code technique structuré** (ex: `2207K`, `SNU507`), pas une description lisible. Exemples valides : `{serie}{diametre}{type}`, `U{taille}`, `SNU {taille}`. Exemples à éviter : `Roulement série {serie} diamètre {diametre}`.
### Normalisation des valeurs
Chaque valeur de CustomField est normalisée avant insertion dans la formule :
- `trim()` — supprime les espaces en début/fin
- `mb_strtoupper()` — convertit en majuscules
Cela garantit que `k``K`, ` 22 ``22`, etc. À terme, des transformations plus avancées (padding, formatage numérique) pourront être ajoutées via une syntaxe dans la formule (ex: `{diametre:pad2}`), mais la V1 se limite à trim+uppercase.
### Why `onFlush` instead of `prePersist`/`preUpdate`?
`referenceAuto` doit être recalculé non seulement quand la Piece change, mais aussi quand ses CustomFieldValues sont créés, modifiés ou **supprimés**. `onFlush` intercepte tous ces cas en un seul subscriber. De plus, les CFV nouvellement insérés ne sont pas encore en base pendant `onFlush`, donc le subscriber les ajoute manuellement à la collection en mémoire avant recalcul.
### Why no `getCustomFieldValueByName()` on Piece?
La logique de résolution des noms de champs est dans le service `ReferenceAutoGenerator.buildValueMap()`, pas dans l'entité. L'entité reste neutre — elle expose sa collection `customFieldValues`, et le service s'occupe du mapping nom → valeur normalisée.
### Read-only via API
Le setter `setReferenceAuto()` est marqué `@internal`. Le subscriber écrase toute valeur sur chaque flush. La protection est double : intention documentée + enforcement technique.
### Éligibilité implicite
L'absence de `referenceFormula` sur un ModelType signifie implicitement que ce type n'est pas éligible à la génération automatique. Pas besoin d'un flag booléen séparé.
### Extensibilité future
Le périmètre actuel est **Piece uniquement**. Si Composant ou Product ont besoin d'un mécanisme similaire, le `ReferenceAutoGenerator` peut être généralisé via une interface, et le subscriber étendu. Mais YAGNI — on n'implémente que ce qui est nécessaire maintenant.
### Limitation V1 : recalcul sur changement de formule ModelType
Si un admin modifie la `referenceFormula` d'un ModelType, les `referenceAuto` des pièces existantes ne sont **pas** recalculées automatiquement. Le subscriber ne réagit qu'aux changements sur Piece et CustomFieldValue, pas sur ModelType. Un recalcul batch (commande Symfony) pourra être ajouté en V2 si nécessaire. C'est un compromis V1 accepté volontairement.
### Column name mapping
PostgreSQL column names are always lowercase. Doctrine uses the PHP property name as column name, which PG lowercases:
- `$referenceFormula``referenceformula`
- `$requiredFieldsForReference``requiredfieldsforreference`
- `$referenceAuto``referenceauto`
No explicit `name` attribute needed — this follows the existing pattern (`typePieceId``typepieceid`, `createdAt``createdat`).

View File

@@ -0,0 +1,467 @@
# Supplier References Frontend 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:** Display and edit supplier references (supplierReference) per constructeur in entity detail/edit views.
**Architecture:** Keep ConstructeurSelect for selecting constructeur IDs. Add a table below showing selected constructeurs with editable supplierReference fields. On save, sync constructeur links via dedicated Link API endpoints (create/delete/patch) after the entity save. Fetch links separately when loading an entity.
**Tech Stack:** Nuxt 4 / Vue 3 Composition API / TypeScript / TailwindCSS 4 / DaisyUI 5
---
## File Structure
### Backend changes (minor)
- Modify: `src/Entity/MachineConstructeurLink.php` — add SearchFilter
- Modify: `src/Entity/PieceConstructeurLink.php` — add SearchFilter
- Modify: `src/Entity/ComposantConstructeurLink.php` — add SearchFilter
- Modify: `src/Entity/ProductConstructeurLink.php` — add SearchFilter
### Frontend new files
- Create: `app/composables/useConstructeurLinks.ts` — CRUD + sync logic for constructeur links
- Create: `app/components/ConstructeurLinksTable.vue` — table of selected constructeurs with supplierReference inputs
### Frontend modified files
- Modify: `app/shared/constructeurUtils.ts` — add ConstructeurLinkEntry type, update uniqueConstructeurIds to handle link format
- Modify: `app/composables/usePieces.ts` — stop sending constructeurIds in entity payload
- Modify: `app/composables/useComposants.ts` — same
- Modify: `app/composables/useProducts.ts` — same
- Modify: `app/composables/useMachines.ts` — same
- Modify: `app/composables/usePieceEdit.ts` — manage links instead of IDs
- Modify: `app/composables/useComponentEdit.ts` — same
- Modify: `app/composables/useProductEdit.ts` — same (if exists, or inline in page)
- Modify: `app/composables/useMachineDetailData.ts` — manage links
- Modify: `app/composables/useMachineDetailUpdates.ts` — sync links on save
- Modify: `app/pages/piece/[id].vue` — add ConstructeurLinksTable
- Modify: `app/pages/component/[id]/index.vue` — add table
- Modify: `app/pages/component/[id]/edit.vue` — add table
- Modify: `app/pages/product/[id]/index.vue` — add table
- Modify: `app/pages/product/[id]/edit.vue` — add table
- Modify: `app/pages/machine/[id].vue` — add table
- Modify: `app/pages/pieces/create.vue` — add table
- Modify: `app/pages/component/create.vue` — add table
- Modify: `app/pages/product/create.vue` — add table
- Modify: `app/components/PieceItem.vue` — update constructeur display for machine structure
- Modify: `app/components/ComponentItem.vue` — same
- Modify: `app/components/machine/MachineInfoCard.vue` — add table
---
### Task F1: Backend — Add SearchFilter on Link entities
**Files:**
- Modify: `src/Entity/MachineConstructeurLink.php`
- Modify: `src/Entity/PieceConstructeurLink.php`
- Modify: `src/Entity/ComposantConstructeurLink.php`
- Modify: `src/Entity/ProductConstructeurLink.php`
- [ ] **Step 1: Add SearchFilter to each Link entity**
Add `ApiFilter` import and filter attribute to each entity's `#[ApiResource]`. Example for PieceConstructeurLink:
```php
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
// Add after #[ApiResource(...)]
#[ApiFilter(SearchFilter::class, properties: ['piece' => 'exact', 'constructeur' => 'exact'])]
```
For each entity, filter on the appropriate parent property:
- MachineConstructeurLink: `['machine' => 'exact', 'constructeur' => 'exact']`
- PieceConstructeurLink: `['piece' => 'exact', 'constructeur' => 'exact']`
- ComposantConstructeurLink: `['composant' => 'exact', 'constructeur' => 'exact']`
- ProductConstructeurLink: `['product' => 'exact', 'constructeur' => 'exact']`
Also add serialization groups to expose link data in API responses. Add `#[Groups]` to `id`, entity relation, `constructeur`, and `supplierReference` properties.
- [ ] **Step 2: Run php-cs-fixer**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/*ConstructeurLink.php
git commit --no-verify -m "feat(constructeur) : add SearchFilter on ConstructeurLink entities"
```
---
### Task F2: Frontend — Add types + useConstructeurLinks composable
**Files:**
- Modify: `Inventory_frontend/app/shared/constructeurUtils.ts`
- Create: `Inventory_frontend/app/composables/useConstructeurLinks.ts`
- [ ] **Step 1: Add ConstructeurLinkEntry type to constructeurUtils.ts**
Add after the existing `ConstructeurSummary` interface:
```typescript
export interface ConstructeurLinkEntry {
linkId?: string // ID of the Link entity (undefined if not yet saved)
constructeurId: string
constructeur?: ConstructeurSummary | null
supplierReference: string | null
}
```
Add helper functions:
```typescript
export const constructeurIdsFromLinks = (links: ConstructeurLinkEntry[]): string[] =>
links.map(l => l.constructeurId).filter(Boolean)
export const parseConstructeurLinksFromApi = (
apiLinks: any[],
): ConstructeurLinkEntry[] => {
if (!Array.isArray(apiLinks)) return []
return apiLinks
.filter(link => link && typeof link === 'object')
.map(link => ({
linkId: link.id || link['@id']?.split('/').pop(),
constructeurId: typeof link.constructeur === 'string'
? link.constructeur.split('/').pop()!
: link.constructeur?.id || '',
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
supplierReference: link.supplierReference ?? null,
}))
}
```
- [ ] **Step 2: Create useConstructeurLinks.ts**
```typescript
import { useApi } from '~/composables/useApi'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
type EntityType = 'machine' | 'piece' | 'composant' | 'product'
const ENDPOINTS: Record<EntityType, string> = {
machine: '/machine_constructeur_links',
piece: '/piece_constructeur_links',
composant: '/composant_constructeur_links',
product: '/product_constructeur_links',
}
const ENTITY_FIELD: Record<EntityType, string> = {
machine: 'machine',
piece: 'piece',
composant: 'composant',
product: 'product',
}
export function useConstructeurLinks() {
const { get, post, patch, del } = useApi()
const fetchLinks = async (
entityType: EntityType,
entityId: string,
): Promise<ConstructeurLinkEntry[]> => {
const endpoint = ENDPOINTS[entityType]
const field = ENTITY_FIELD[entityType]
const result = await get(`${endpoint}?${field}=/api/${field}s/${entityId}`)
if (!result.success || !result.data) return []
const members = (result.data as any)['hydra:member'] ?? result.data
if (!Array.isArray(members)) return []
return members.map((link: any) => ({
linkId: link.id ?? link['@id']?.split('/').pop(),
constructeurId: typeof link.constructeur === 'string'
? link.constructeur.split('/').pop()!
: link.constructeur?.id ?? '',
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
supplierReference: link.supplierReference ?? null,
}))
}
const syncLinks = async (
entityType: EntityType,
entityId: string,
originalLinks: ConstructeurLinkEntry[],
formLinks: ConstructeurLinkEntry[],
): Promise<void> => {
const endpoint = ENDPOINTS[entityType]
const field = ENTITY_FIELD[entityType]
const entityIri = `/api/${field}s/${entityId}`
const originalMap = new Map(originalLinks.map(l => [l.constructeurId, l]))
const formMap = new Map(formLinks.map(l => [l.constructeurId, l]))
// Delete removed links
for (const [cId, orig] of originalMap) {
if (!formMap.has(cId) && orig.linkId) {
await del(`${endpoint}/${orig.linkId}`)
}
}
// Create new links
for (const [cId, form] of formMap) {
if (!originalMap.has(cId)) {
await post(endpoint, {
[field]: entityIri,
constructeur: `/api/constructeurs/${cId}`,
supplierReference: form.supplierReference || null,
})
}
}
// Patch modified supplierReference
for (const [cId, form] of formMap) {
const orig = originalMap.get(cId)
if (orig?.linkId && orig.supplierReference !== form.supplierReference) {
await patch(`${endpoint}/${orig.linkId}`, {
supplierReference: form.supplierReference || null,
})
}
}
}
return { fetchLinks, syncLinks }
}
```
- [ ] **Step 3: Commit**
```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinkEntry type and useConstructeurLinks composable"
```
---
### Task F3: Frontend — Create ConstructeurLinksTable component
**Files:**
- Create: `Inventory_frontend/app/components/ConstructeurLinksTable.vue`
- [ ] **Step 1: Create the component**
A table showing selected constructeurs with editable supplierReference fields:
```vue
<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]
updated[index] = { ...updated[index], 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)
emit('remove', removed.constructeurId)
}
</script>
```
- [ ] **Step 2: Commit**
```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinksTable component"
```
---
### Task F4: Frontend — Update piece edit flow (model case)
**Files:**
- Modify: `Inventory_frontend/app/composables/usePieceEdit.ts`
- Modify: `Inventory_frontend/app/pages/piece/[id].vue`
- Modify: `Inventory_frontend/app/composables/usePieces.ts`
This task establishes the pattern for all entity types.
- [ ] **Step 1: Update usePieceEdit.ts**
Key changes:
1. Import `useConstructeurLinks` and new types
2. Add `constructeurLinks: ref<ConstructeurLinkEntry[]>([])` alongside existing `editionForm.constructeurIds`
3. On load: fetch links via `fetchLinks('piece', pieceId)` and populate `constructeurLinks`
4. Derive `editionForm.constructeurIds` from links (for ConstructeurSelect compatibility)
5. When ConstructeurSelect changes IDs: sync the links array (add new entries, keep existing ones)
6. On save: remove constructeurIds from entity payload, call `syncLinks` after entity save
- [ ] **Step 2: Update piece/[id].vue page**
Add ConstructeurLinksTable below ConstructeurSelect:
- In edit mode: show ConstructeurLinksTable with v-model bound to constructeurLinks
- In view mode: show ConstructeurLinksTable with readonly
- Wire ConstructeurSelect changes to update constructeurLinks (add new entries with empty supplierReference)
- [ ] **Step 3: Update usePieces.ts**
In `createPiece()` and `updatePieceData()`: stop wrapping payload with `buildConstructeurRequestPayload()`. Remove constructeurIds/constructeurs from the payload before sending.
- [ ] **Step 4: Lint and typecheck**
```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : update piece edit flow with supplier references"
```
---
### Task F5: Frontend — Update composant edit flow
Same pattern as Task F4 but for composants.
**Files:**
- Modify: `Inventory_frontend/app/composables/useComponentEdit.ts`
- Modify: `Inventory_frontend/app/pages/component/[id]/index.vue`
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue`
- Modify: `Inventory_frontend/app/composables/useComposants.ts`
- Modify: `Inventory_frontend/app/pages/component/create.vue`
---
### Task F6: Frontend — Update product edit flow
Same pattern as Task F4 but for products.
**Files:**
- Modify: product edit composable (if exists) or inline pages
- Modify: `Inventory_frontend/app/pages/product/[id]/index.vue`
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue`
- Modify: `Inventory_frontend/app/composables/useProducts.ts`
- Modify: `Inventory_frontend/app/pages/product/create.vue`
---
### Task F7: Frontend — Update machine detail flow
Machine uses a different architecture (MachineStructureController, useMachineDetailData/Updates).
**Files:**
- Modify: `Inventory_frontend/app/composables/useMachineDetailData.ts`
- Modify: `Inventory_frontend/app/composables/useMachineDetailUpdates.ts`
- Modify: `Inventory_frontend/app/pages/machine/[id].vue`
- Modify: `Inventory_frontend/app/components/machine/MachineInfoCard.vue`
- Modify: `Inventory_frontend/app/composables/useMachines.ts`
Key differences:
- Machine data comes from `/api/machines/{id}/structure` (custom controller) which already returns the new constructeur link format
- Machine updates go through `updateMachineApi` which currently sends `constructeurIds`
- Need to adapt to read links from structure response and sync on save
---
### Task F8: Frontend — Update machine structure components (PieceItem, ComponentItem)
**Files:**
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
- Modify: `Inventory_frontend/app/components/ComponentItem.vue`
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
- Display supplierReference alongside constructeur name
- Use syncLinks for inline updates
---
### Task F9: Frontend — Update create pages
**Files:**
- Modify: `Inventory_frontend/app/pages/pieces/create.vue`
- Modify: `Inventory_frontend/app/pages/component/create.vue`
- Modify: `Inventory_frontend/app/pages/product/create.vue`
On creation pages, there are no existing links. The flow is:
1. User selects constructeurs + optionally fills supplierReference
2. After entity creation, create all the links
3. Use `syncLinks` with empty originalLinks
---
### Task F10: Frontend — Cleanup and final verification
- [ ] Remove `buildConstructeurRequestPayload` from constructeurUtils.ts if no longer used
- [ ] Run `npm run lint:fix`
- [ ] Run `npx nuxi typecheck`
- [ ] Run `npm run build`
- [ ] Manual verification in browser

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,669 @@
# MCP Server — Inventory Project — Design Spec
**Date :** 2026-03-16
**Version projet :** 1.9.1
**Statut :** Draft (post-review v2)
---
## 1. Objectif
Exposer l'intégralité de l'API Inventory (machines, pièces, composants, produits, sites, constructeurs, custom fields, documents, commentaires, audit) via un serveur MCP (Model Context Protocol) intégré directement dans l'application Symfony.
Le serveur doit être compatible avec tous les clients MCP majeurs : Claude Code, Claude Desktop, ChatGPT Desktop, Codex, et tout client supportant le protocole MCP.
## 2. Contraintes
| Contrainte | Détail |
|---|---|
| **Réseau** | Machine hébergée sur un réseau fermé d'entreprise. Les clients distants (Claude Desktop, ChatGPT, Codex) accèdent via un tunnel chiffré (Cloudflare/WireGuard/SSH) |
| **Auth** | Pass-through : chaque client fournit ses propres credentials (profileId + password). Le serveur MCP charge le profil correspondant et applique ses rôles. Les actions sont traçables par utilisateur dans l'audit log |
| **Transport** | Dual : stdio pour usage local (Claude Code sur la même machine) + HTTP Streamable/SSE pour clients distants via tunnel |
| **Stack** | PHP / Symfony 8.0 — le serveur MCP vit dans l'application existante, pas de service séparé |
| **Scope** | Lecture + écriture complète — les outils couvrent tout le CRUD + les opérations métier |
## 3. Stack technique
| Composant | Choix |
|---|---|
| SDK MCP | `symfony/mcp-bundle` v0.6.0 + `mcp/sdk` ^0.4 (officiel Symfony + PHP Foundation + Anthropic) |
| Transport stdio | `bin/console mcp:server` (dans le container Docker) |
| Transport HTTP | Endpoint `/_mcp` sur le même port que l'API (8081) |
| Auth HTTP | Custom Symfony Authenticator (`McpHeaderAuthenticator`) intégré au firewall Symfony |
| Auth stdio | Token synthétique chargé depuis `$_ENV` au boot |
| Rate limiting | `symfony/rate-limiter` sur les tentatives d'auth échouées |
| Accès données | Repositories Doctrine directs (pas de hop HTTP interne) |
**Note :** Le bundle est expérimental et non couvert par la BC Promise de Symfony. L'implémentation inclut un spike/PoC initial (étape 1 du plan) pour valider la compatibilité de l'API réelle du bundle avec ce design.
## 4. Architecture
```
┌─────────────────────────────────────────────────────┐
│ Docker Compose (réseau fermé entreprise) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ php-inventory-apache (Symfony 8) │ │
│ │ │ │
│ │ /api/* ← API REST existante │ │
│ │ /_mcp ← Endpoint MCP HTTP (SSE) │ │
│ │ bin/console mcp:server ← Transport stdio │ │
│ │ │ │
│ │ Firewall Symfony : │ │
│ │ ^/api → SessionProfileAuthenticator │ │
│ │ ^/_mcp → McpHeaderAuthenticator │ │
│ │ │ │
│ │ src/Mcp/Tool/ ← Tools MCP │ │
│ │ src/Mcp/Resource/ ← Resources MCP │ │
│ │ src/Mcp/Security/ ← Authenticator + Guard │ │
│ └──────────┬───────────────────────────────────┘ │
│ │ réseau Docker interne │
│ ┌──────────▼──────────┐ │
│ │ PostgreSQL 16 │ │
│ └─────────────────────┘ │
└──────────────────┬──────────────────────────────────┘
│ tunnel (chiffré)
┌──────────────▼──────────────────┐
│ Postes utilisateurs │
│ - Claude Desktop → HTTP/SSE │
│ - ChatGPT Desktop → HTTP/SSE │
│ - Codex → HTTP/SSE │
│ - Claude Code local → stdio │
└─────────────────────────────────┘
```
Le serveur MCP accède directement aux repositories Doctrine et aux services Symfony existants. Pas de double sérialisation — les tools appellent les mêmes repositories/services que les controllers REST.
## 5. Authentification pass-through
### 5.1 Firewall Symfony — intégration sécurité
Un firewall dédié pour `/_mcp` avec un authenticator custom. Cela garantit que `$security->getUser()` retourne le bon Profile, que la hiérarchie des rôles fonctionne via `is_granted()`, et que l'audit log trace le bon acteur.
```yaml
# config/packages/security.yaml (ajout)
security:
firewalls:
mcp:
pattern: ^/_mcp
stateless: true
custom_authenticators:
- App\Mcp\Security\McpHeaderAuthenticator
```
Le `McpHeaderAuthenticator` implémente `AuthenticatorInterface` :
1. Extrait `X-Profile-Id` et `X-Profile-Password` des headers
2. Charge le profil via `ProfileRepository`
3. Vérifie le password hash via `UserPasswordHasherInterface`
4. Retourne un `Passport` avec le Profile comme User
5. Symfony gère le reste (token, rôles, hiérarchie)
Cela permet à `AbstractAuditSubscriber.resolveActorProfileId()` de résoudre l'acteur via `$security->getUser()` sans aucune modification du code existant.
### 5.2 Transport stdio — token synthétique
Pour le transport stdio (pas de requête HTTP), un `EventSubscriber` sur `console.command` (quand la commande est `mcp:server`) :
1. Lit `MCP_PROFILE_ID` et `MCP_PROFILE_PASSWORD` depuis `$_ENV`
2. Valide les credentials
3. Injecte un `UsernamePasswordToken` synthétique dans le `TokenStorage` avec le Profile
### 5.3 Rate limiting — protection brute-force
```yaml
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 5
interval: '1 minute'
```
Le `McpHeaderAuthenticator` consomme le rate limiter sur chaque tentative échouée (clé = IP). Après 5 échecs en 1 minute, toute tentative est rejetée avec une erreur MCP `429 Too Many Requests`.
### 5.4 Vérification des rôles
Chaque tool déclare un rôle minimum. L'authenticator Symfony gère la hiérarchie :
| Rôle | Droits MCP |
|---|---|
| `ROLE_VIEWER` | Tous les tools de lecture (list, get, search, history) |
| `ROLE_GESTIONNAIRE` | Lecture + écriture (create, update, delete, slots, clone) |
| `ROLE_ADMIN` | Tout + gestion profils |
Les tools utilisent `$this->security->isGranted('ROLE_XXX')` pour vérifier, bénéficiant de la hiérarchie Symfony standard.
## 6. Catalogue des Tools MCP
### 6.1 Tools de haut niveau (métier)
| Tool | Description | Paramètres principaux | Rôle min |
|---|---|---|---|
| `search_inventory` | Recherche globale dans toutes les entités (machines, pièces, composants, produits, sites, constructeurs) | `query: string`, `types?: string[]`, `limit?: int` | VIEWER |
| `get_machine_structure` | Hiérarchie complète d'une machine : composants, pièces, produits, custom fields, slots | `machineId: string` | VIEWER |
| `clone_machine` | Clone une machine avec sa structure complète | `machineId: string`, `name: string`, `siteId: string`, `reference?: string` | GESTIONNAIRE |
| `get_entity_history` | Historique d'audit d'une entité | `entityType: string`, `entityId: string` | VIEWER |
| `get_activity_log` | Journal d'activité global | `page?: int`, `limit?: int`, `entityType?: string`, `action?: string` | VIEWER |
| `get_dashboard_stats` | Compteurs globaux (machines, pièces, composants, produits, commentaires ouverts) | aucun | VIEWER |
| `sync_model_type` | Preview ou exécution de la synchronisation skeleton d'un ModelType | `modelTypeId: string`, `action: "preview"\|"sync"`, `structure?: object` | GESTIONNAIRE |
### 6.2 Tools CRUD — Machines
| Tool | Description | Rôle min |
|---|---|---|
| `list_machines` | Lister les machines avec filtres (nom, référence, site) et pagination | VIEWER |
| `get_machine` | Détail d'une machine par ID | VIEWER |
| `create_machine` | Créer une machine (nom, référence, siteId, constructeurs) | GESTIONNAIRE |
| `update_machine` | Mise à jour partielle d'une machine | GESTIONNAIRE |
| `delete_machine` | Supprimer une machine | GESTIONNAIRE |
### 6.3 Tools CRUD — Composants
| Tool | Description | Rôle min |
|---|---|---|
| `list_composants` | Lister les composants avec filtres et pagination | VIEWER |
| `get_composant` | Détail d'un composant par ID (incluant ses slots) | VIEWER |
| `create_composant` | Créer un composant (nom, référence, modelTypeId, constructeurs). Retourne l'ID + les slots vides auto-générés | GESTIONNAIRE |
| `update_composant` | Mise à jour partielle | GESTIONNAIRE |
| `delete_composant` | Supprimer un composant | GESTIONNAIRE |
### 6.4 Tools CRUD — Pièces
| Tool | Description | Rôle min |
|---|---|---|
| `list_pieces` | Lister les pièces avec filtres et pagination | VIEWER |
| `get_piece` | Détail d'une pièce par ID (incluant ses product-slots) | VIEWER |
| `create_piece` | Créer une pièce (nom, référence, modelTypeId, constructeurs). Retourne l'ID + product-slots auto-générés | GESTIONNAIRE |
| `update_piece` | Mise à jour partielle | GESTIONNAIRE |
| `delete_piece` | Supprimer une pièce | GESTIONNAIRE |
### 6.5 Tools CRUD — Produits
| Tool | Description | Rôle min |
|---|---|---|
| `list_products` | Lister les produits avec filtres et pagination | VIEWER |
| `get_product` | Détail d'un produit par ID | VIEWER |
| `create_product` | Créer un produit (nom, référence, modelTypeId, prix (string), constructeurs) | GESTIONNAIRE |
| `update_product` | Mise à jour partielle | GESTIONNAIRE |
| `delete_product` | Supprimer un produit | GESTIONNAIRE |
### 6.6 Tools CRUD — Sites
| Tool | Description | Rôle min |
|---|---|---|
| `list_sites` | Lister les sites | VIEWER |
| `get_site` | Détail d'un site par ID | VIEWER |
| `create_site` | Créer un site | GESTIONNAIRE |
| `update_site` | Mise à jour partielle | GESTIONNAIRE |
| `delete_site` | Supprimer un site | GESTIONNAIRE |
### 6.7 Tools CRUD — Constructeurs
| Tool | Description | Rôle min |
|---|---|---|
| `list_constructeurs` | Lister les constructeurs/fournisseurs | VIEWER |
| `get_constructeur` | Détail d'un constructeur par ID | VIEWER |
| `create_constructeur` | Créer un constructeur | GESTIONNAIRE |
| `update_constructeur` | Mise à jour partielle | GESTIONNAIRE |
| `delete_constructeur` | Supprimer un constructeur | GESTIONNAIRE |
### 6.8 Tools — Commentaires (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_comments` | Lister les commentaires d'une entité | VIEWER |
| `create_comment` | Créer un commentaire sur une entité | VIEWER |
| `resolve_comment` | Marquer un commentaire comme résolu | GESTIONNAIRE |
| `get_unresolved_comments_count` | Nombre de commentaires non résolus | VIEWER |
### 6.9 Tools — Custom Fields (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_custom_field_values` | Lister les custom field values d'une entité | VIEWER |
| `upsert_custom_field_values` | Créer ou mettre à jour des custom field values | GESTIONNAIRE |
| `delete_custom_field_value` | Supprimer une custom field value | GESTIONNAIRE |
### 6.10 Tools — Documents (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_documents` | Lister les documents d'une entité | VIEWER |
| `delete_document` | Supprimer un document | GESTIONNAIRE |
> **Limitation connue :** L'upload de documents n'est pas supporté via MCP. Le protocole MCP échange du JSON — l'upload de fichiers binaires (multipart/form-data) n'est pas compatible. Les uploads doivent se faire via l'API REST `/api/documents` (POST multipart). Cette limitation pourra être réévaluée si le protocole MCP ajoute un support binaire.
### 6.11 Tools — Machine Links (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_machine_links` | Lister les liens composant/pièce/produit d'une machine | VIEWER |
| `add_machine_links` | Ajouter des liens machine↔composant/pièce/produit | GESTIONNAIRE |
| `update_machine_link` | Modifier un lien (quantité, overrides) | GESTIONNAIRE |
| `remove_machine_link` | Supprimer un lien | GESTIONNAIRE |
### 6.12 Tools — Slots
| Tool | Description | Rôle min |
|---|---|---|
| `list_slots` | Lister les slots d'un composant ou pièce avec état (rempli/vide, requirement). Paramètre `entityType: "composant"\|"piece"` + `entityId` | VIEWER |
| `update_slots` | Remplir un ou plusieurs slots. Paramètre `slots: [{slotId, selectedPieceId?\|selectedProductId?\|selectedComposantId?}]` | GESTIONNAIRE |
> **Note :** Un seul tool `list_slots` et un seul `update_slots` — ils acceptent un paramètre `entityType` pour dispatcher vers composant ou pièce. Un seul fichier d'implémentation par tool.
### 6.13 Tools — ModelTypes
| Tool | Description | Rôle min |
|---|---|---|
| `list_model_types` | Lister les ModelTypes par catégorie avec skeleton requirements | VIEWER |
| `get_model_type` | Détail complet d'un ModelType (requirements + custom fields) | VIEWER |
| `create_model_type` | Créer un ModelType | GESTIONNAIRE |
| `update_model_type` | Modifier un ModelType | GESTIONNAIRE |
| `delete_model_type` | Supprimer un ModelType | GESTIONNAIRE |
**Total : ~55 tools** (splittés pour des schémas JSON non-ambigus, meilleure compatibilité LLM)
> **Note :** Les tools d'administration des profils (`list_profiles`, `create_profile`, etc.) ne sont pas inclus — la gestion des profils reste exclusivement via l'API REST `/api/admin/profiles` (ROLE_ADMIN). Cela évite d'exposer la gestion des comptes/mots de passe via MCP.
## 7. Resources MCP
| URI | Description | Contenu |
|---|---|---|
| `inventory://schema/entities` | Schéma de toutes les entités | Nom, champs (nom, type, nullable, description) pour chaque entité |
| `inventory://model-types/{category}` | ModelTypes par catégorie | Liste des ModelTypes avec leurs skeleton requirements et custom fields |
| `inventory://roles` | Hiérarchie des rôles | Rôles et permissions associées pour guider le LLM |
| `inventory://stats` | Statistiques globales | Compteurs de chaque entité, commentaires ouverts |
## 8. Workflows de création guidés
### 8.1 Créer un Composant complet
```
1. list_model_types(category: "composant")
→ Choisir le type de composant
2. get_model_type(modelTypeId)
→ Voir les skeleton requirements : pièces, produits, sous-composants attendus
→ Voir les custom fields de chaque requirement
3. create_composant(name, reference, modelTypeId, constructeurs)
→ Reçoit: { id, slots: [{slotId, type, requirementName}, ...] }
4. search_inventory(query: "Roulement", types: ["piece"])
→ Trouver les pièces candidates pour chaque slot
5. update_slots([{slotId, selectedPieceId}, {slotId, selectedProductId}, ...])
→ Remplir les slots
6. upsert_custom_field_values(entityType: "composant", entityId,
fields: [{name: "Tension", value: "220V"}, ...])
→ Remplir les custom fields
```
### 8.2 Créer une Pièce complète
```
1. list_model_types(category: "piece")
2. get_model_type(modelTypeId)
3. create_piece(name, reference, modelTypeId, constructeurs)
→ Reçoit: { id, productSlots: [{slotId, requirementName}, ...] }
4. search_inventory(query: "...", types: ["product"])
5. update_slots([{slotId, selectedProductId}, ...])
6. upsert_custom_field_values(...)
```
### 8.3 Créer un Produit
```
1. list_model_types(category: "product")
2. create_product(name, reference, modelTypeId, prix, constructeurs)
3. upsert_custom_field_values(...)
```
### 8.4 Créer une Machine complète (de bas en haut)
```
1. Créer les produits nécessaires (§8.3)
2. Créer les pièces avec les produits dans les slots (§8.2)
3. Créer les composants avec les pièces dans les slots (§8.1)
4. list_sites → choisir le site
5. create_machine(name, reference, siteId, constructeurs)
6. add_machine_links(machineId, links: [
{type: "composant", entityId, quantity},
{type: "piece", entityId, quantity},
{type: "product", entityId}
])
7. upsert_custom_field_values(entityType: "machine", machineId, ...)
```
## 9. Pagination
Toutes les tools `list_*` utilisent un contrat de pagination uniforme :
### Paramètres d'entrée
| Paramètre | Type | Default | Description |
|---|---|---|---|
| `page` | int | 1 | Numéro de page (1-indexed) |
| `limit` | int | 30 | Nombre d'items par page (max 100) |
### Format de réponse
```json
{
"items": [...],
"total": 142,
"page": 1,
"limit": 30,
"pageCount": 5
}
```
## 10. Format des erreurs
Toutes les erreurs MCP suivent un format uniforme via `isError: true` dans la réponse tool :
```json
{
"isError": true,
"content": [{"type": "text", "text": "Permission denied: ROLE_GESTIONNAIRE required for create_machine"}]
}
```
### Catégories d'erreurs
| Code | Description | Exemple |
|---|---|---|
| `auth_error` | Credentials invalides ou manquants | "Authentication failed: invalid password" |
| `permission_denied` | Rôle insuffisant pour l'opération | "Permission denied: ROLE_GESTIONNAIRE required" |
| `not_found` | Entité introuvable | "Machine not found: cl4a8b..." |
| `validation_error` | Données invalides | "Validation failed: name is required" |
| `rate_limited` | Trop de tentatives d'auth échouées | "Rate limited: try again in 45 seconds" |
| `internal_error` | Erreur serveur inattendue | "Internal error: database connection failed" |
Le champ `text` inclut toujours la catégorie en préfixe pour que le LLM puisse adapter son comportement.
## 11. Configuration
### 11.1 Symfony — config/packages/mcp.yaml
```yaml
mcp:
app: 'inventory'
version: '%env(file:resolve:VERSION)%'
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
instructions: |
Serveur MCP pour gérer un inventaire industriel.
Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur.
Utilisez search_inventory pour chercher dans toutes les entités.
Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce.
Consultez la resource inventory://schema/entities pour voir le schéma complet.
Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP.
client_transports:
stdio: true
http: true
http:
path: /_mcp
session:
store: file
directory: '%kernel.cache_dir%/mcp-sessions'
ttl: 3600
```
### 11.2 Security — config/packages/security.yaml (ajout firewall)
```yaml
security:
firewalls:
# AVANT le firewall api existant
mcp:
pattern: ^/_mcp
stateless: true
custom_authenticators:
- App\Mcp\Security\McpHeaderAuthenticator
api:
pattern: ^/api
# ... existant ...
```
### 11.3 Rate Limiter — config/packages/rate_limiter.yaml
```yaml
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 5
interval: '1 minute'
```
### 11.4 Routes — config/routes.yaml (ajout)
```yaml
mcp:
resource: .
type: mcp
```
### 11.5 Logging — config/packages/monolog.yaml (ajout)
```yaml
monolog:
channels: ['mcp']
handlers:
mcp:
type: rotating_file
path: '%kernel.logs_dir%/mcp.log'
level: info
channels: ['mcp']
max_files: 30
```
## 12. Configuration des clients
### 12.1 Claude Code (local, stdio via Docker)
Fichier `.mcp.json` à la racine du projet :
```json
{
"mcpServers": {
"inventory": {
"command": "docker",
"args": [
"exec", "-i",
"-e", "MCP_PROFILE_ID=<votre-profile-id>",
"-e", "MCP_PROFILE_PASSWORD=<votre-password>",
"php-inventory-apache",
"php", "bin/console", "mcp:server"
]
}
}
}
```
> **Note :** Les env vars sont passées via les flags `-e` de `docker exec` car le bloc `env` de `.mcp.json` ne les injecte pas dans le container Docker. Si PHP et les dépendances Composer sont disponibles directement sur l'hôte (hors Docker), on peut utiliser `"command": "php", "args": ["bin/console", "mcp:server"]` avec un bloc `env` standard.
### 12.2 Claude Desktop (distant, HTTP via tunnel)
Fichier `claude_desktop_config.json` :
```json
{
"mcpServers": {
"inventory": {
"url": "https://inventory.company-tunnel.com/_mcp",
"headers": {
"X-Profile-Id": "<votre-profile-id>",
"X-Profile-Password": "<votre-password>"
}
}
}
}
```
### 12.3 ChatGPT Desktop (HTTP via tunnel)
Même principe HTTP : URL du tunnel + headers d'auth. Format de config selon la doc ChatGPT MCP.
### 12.4 Codex (HTTP via tunnel)
Même config HTTP que Claude Desktop.
## 13. Structure des fichiers
```
src/
└── Mcp/
├── Tool/
│ ├── SearchInventoryTool.php # search_inventory
│ ├── DashboardStatsTool.php # get_dashboard_stats
│ ├── ActivityLogTool.php # get_activity_log
│ ├── EntityHistoryTool.php # get_entity_history
│ ├── Machine/
│ │ ├── ListMachinesTool.php # list_machines
│ │ ├── GetMachineTool.php # get_machine
│ │ ├── CreateMachineTool.php # create_machine
│ │ ├── UpdateMachineTool.php # update_machine
│ │ ├── DeleteMachineTool.php # delete_machine
│ │ ├── MachineStructureTool.php # get_machine_structure
│ │ ├── CloneMachineTool.php # clone_machine
│ │ ├── ListMachineLinksTool.php # list_machine_links
│ │ ├── AddMachineLinksTool.php # add_machine_links
│ │ ├── UpdateMachineLinkTool.php # update_machine_link
│ │ └── RemoveMachineLinkTool.php # remove_machine_link
│ ├── Composant/
│ │ ├── ListComposantsTool.php # list_composants
│ │ ├── GetComposantTool.php # get_composant
│ │ ├── CreateComposantTool.php # create_composant
│ │ ├── UpdateComposantTool.php # update_composant
│ │ └── DeleteComposantTool.php # delete_composant
│ ├── Piece/
│ │ ├── ListPiecesTool.php # list_pieces
│ │ ├── GetPieceTool.php # get_piece
│ │ ├── CreatePieceTool.php # create_piece
│ │ ├── UpdatePieceTool.php # update_piece
│ │ └── DeletePieceTool.php # delete_piece
│ ├── Slot/
│ │ ├── ListSlotsTool.php # list_slots (dispatche par entityType)
│ │ └── UpdateSlotsTool.php # update_slots
│ ├── Product/
│ │ ├── ListProductsTool.php # list_products
│ │ ├── GetProductTool.php # get_product
│ │ ├── CreateProductTool.php # create_product
│ │ ├── UpdateProductTool.php # update_product
│ │ └── DeleteProductTool.php # delete_product
│ ├── Site/
│ │ ├── ListSitesTool.php # list_sites
│ │ ├── GetSiteTool.php # get_site
│ │ ├── CreateSiteTool.php # create_site
│ │ ├── UpdateSiteTool.php # update_site
│ │ └── DeleteSiteTool.php # delete_site
│ ├── Constructeur/
│ │ ├── ListConstructeursTool.php # list_constructeurs
│ │ ├── GetConstructeurTool.php # get_constructeur
│ │ ├── CreateConstructeurTool.php # create_constructeur
│ │ ├── UpdateConstructeurTool.php # update_constructeur
│ │ └── DeleteConstructeurTool.php # delete_constructeur
│ ├── ModelType/
│ │ ├── ListModelTypesTool.php # list_model_types
│ │ ├── GetModelTypeTool.php # get_model_type
│ │ ├── CreateModelTypeTool.php # create_model_type
│ │ ├── UpdateModelTypeTool.php # update_model_type
│ │ ├── DeleteModelTypeTool.php # delete_model_type
│ │ └── SyncModelTypeTool.php # sync_model_type
│ ├── CustomField/
│ │ ├── ListCustomFieldValuesTool.php # list_custom_field_values
│ │ ├── UpsertCustomFieldValuesTool.php # upsert_custom_field_values
│ │ └── DeleteCustomFieldValueTool.php # delete_custom_field_value
│ ├── Document/
│ │ ├── ListDocumentsTool.php # list_documents
│ │ └── DeleteDocumentTool.php # delete_document
│ └── Comment/
│ ├── ListCommentsTool.php # list_comments
│ ├── CreateCommentTool.php # create_comment
│ ├── ResolveCommentTool.php # resolve_comment
│ └── UnresolvedCountTool.php # get_unresolved_comments_count
├── Resource/
│ ├── SchemaResource.php # inventory://schema/entities
│ ├── ModelTypesResource.php # inventory://model-types/{category}
│ ├── RolesResource.php # inventory://roles
│ └── StatsResource.php # inventory://stats
└── Security/
└── McpHeaderAuthenticator.php # Symfony Authenticator pour firewall MCP
docs/
└── mcp/
└── README.md # Guide utilisateur complet
```
## 14. Documentation utilisateur (docs/mcp/README.md)
Le guide contiendra :
1. **Introduction** — Qu'est-ce que le MCP Inventory, à quoi ça sert, quels clients sont supportés
2. **Prérequis** — Profil avec rôle suffisant, accès au tunnel, client MCP compatible
3. **Installation & configuration par client** — Exemples copier-coller pour :
- Claude Code (stdio via Docker)
- Claude Desktop (HTTP via tunnel)
- ChatGPT Desktop (HTTP via tunnel)
- Codex (HTTP via tunnel)
4. **Catalogue des tools** — Tableau complet avec nom, description, paramètres, rôle requis
5. **Workflows guidés** — Comment créer une machine, un composant, une pièce, un produit (étape par étape avec exemples d'appels)
6. **Resources disponibles** — URIs et contenu exposé
7. **Rôles & permissions** — Quel rôle permet quelles actions
8. **Format des erreurs** — Catégories et exemples
9. **Limitations connues** — Upload documents non supporté via MCP
10. **Troubleshooting** — Erreurs courantes (auth failed, tunnel down, rôle insuffisant, rate limited)
## 15. Sécurité
| Mesure | Détail |
|---|---|
| **Firewall Symfony** | `/_mcp` a son propre firewall avec `McpHeaderAuthenticator` — intégré au système de sécurité standard |
| **Vérification rôle** | Chaque tool vérifie via `$security->isGranted()` avec hiérarchie des rôles |
| **Audit trail** | `AbstractAuditSubscriber.resolveActorProfileId()` fonctionne nativement car `$security->getUser()` retourne le Profile authentifié |
| **Rate limiting** | 5 tentatives d'auth échouées par minute par IP → rejet |
| **Transport chiffré** | Le tunnel assure le chiffrement en transit pour les clients distants |
| **Pas de secrets dans le code** | Credentials dans env vars (stdio) ou headers (HTTP), jamais en dur |
| **Sessions MCP** | TTL 1h, stockage fichier, nettoyage automatique |
| **CORS** | Non nécessaire — les clients MCP sont des apps natives (pas des navigateurs). Le tunnel termine la connexion côté serveur. À réévaluer si un client browser-based apparaît |
## 16. Backward Compatibility
Les tools MCP suivent une politique additive :
- **Ajouts** : nouveaux tools, nouveaux paramètres optionnels → toujours OK
- **Suppressions** : marquer un tool comme deprecated pendant 1 version avant suppression
- **Breaking changes** : changer le type/nom d'un paramètre requis → bumper la version MCP
Le champ `version` dans la config MCP (lu depuis `VERSION`) signale les changements.
## 17. Dépendances à installer
```bash
composer require symfony/mcp-bundle symfony/rate-limiter
```
Le bundle tire `mcp/sdk` automatiquement.
## 18. Tests
Les tools MCP seront testés via :
- **Tests unitaires** : chaque tool testé avec des mocks de repositories, vérification des paramètres et des réponses
- **Tests d'intégration** : appels MCP stdio via `docker exec ... php bin/console mcp:server` avec des fixtures
- **Tests de sécurité** : vérification que les tools rejettent les appels sans auth, avec rôle insuffisant, et après rate limiting
- Pattern : hériter de `AbstractApiTestCase` pour réutiliser les factories existantes (`createProfile()`, `createMachine()`, etc.)
## 19. Spike / PoC initial
Avant l'implémentation complète, une étape de validation :
1. Installer `symfony/mcp-bundle` dans le projet
2. Créer un tool minimal (`get_dashboard_stats`) avec l'attribut `#[McpTool]`
3. Tester le transport stdio : `docker exec -i php-inventory-apache php bin/console mcp:server`
4. Tester le transport HTTP : appel POST sur `/_mcp`
5. Valider que l'authenticator custom fonctionne avec le firewall
6. Confirmer que `$security->getUser()` retourne le bon Profile dans un tool
Si le PoC révèle des incompatibilités avec l'API du bundle, adapter le design avant de continuer.

View File

@@ -0,0 +1,138 @@
# Document Types — Design Spec
Date: 2026-03-23
Status: Approved
## Goal
Add a `type` field to documents so users can classify them (documentation, devis, facture, plan, photo, autre). Users can set the type at upload and change it afterward via a mini-modal.
## Enum Values
| Value | Label |
|-------|-------|
| `documentation` | Documentation |
| `devis` | Devis |
| `facture` | Facture |
| `plan` | Plan |
| `photo` | Photo |
| `autre` | Autre |
Default: `documentation`
## Backend
### 1. PHP Enum
New file: `src/Enum/DocumentType.php`
```php
enum DocumentType: string
{
case DOCUMENTATION = 'documentation';
case DEVIS = 'devis';
case FACTURE = 'facture';
case PLAN = 'plan';
case PHOTO = 'photo';
case AUTRE = 'autre';
}
```
### 2. Entity Change — Document.php
Add column:
```php
#[ORM\Column(type: Types::STRING, length: 20, enumType: DocumentType::class)]
#[Groups(['document:list'])]
private DocumentType $type = DocumentType::DOCUMENTATION;
```
Add getter/setter:
```php
public function getType(): DocumentType { ... }
public function setType(DocumentType $type): static { ... }
```
### 3. API Platform — PATCH operation
Add a `Patch` operation on Document (ROLE_GESTIONNAIRE) to allow updating `name` and `type`. The existing `Put` already exists but PATCH is more appropriate for partial updates.
### 4. DocumentUploadProcessor
Accept optional `type` field from FormData. Validate against enum values, default to `documentation` if absent.
### 5. Migration
```sql
ALTER TABLE documents ADD COLUMN type VARCHAR(20) NOT NULL DEFAULT 'documentation';
-- Classify existing documents by mimeType
UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%';
UPDATE documents SET type = 'autre'
WHERE type = 'documentation'
AND mimetype NOT LIKE 'application/pdf'
AND mimetype NOT LIKE 'image/%';
```
### 6. DocumentQueryController
Add `type` to the response array in `formatDocument()`.
## Frontend
### 1. Type Constants
New file: `app/shared/documentTypes.ts`
```typescript
export const DOCUMENT_TYPES = [
{ value: 'documentation', label: 'Documentation' },
{ value: 'devis', label: 'Devis' },
{ value: 'facture', label: 'Facture' },
{ value: 'plan', label: 'Plan' },
{ value: 'photo', label: 'Photo' },
{ value: 'autre', label: 'Autre' },
] as const
export type DocumentTypeValue = typeof DOCUMENT_TYPES[number]['value']
```
### 2. DocumentUpload.vue — Type select at upload
Add a select dropdown (default: `documentation`) in the upload zone. The selected type applies to all files in the current batch. Pass the type through to `uploadDocuments()`.
### 3. useDocuments composable
- `uploadDocuments()`: accept `type` in the upload context, append to FormData
- New method: `updateDocument(id, { name, type })` — PATCH `/api/documents/{id}` with `application/merge-patch+json`
- Add `type` to the `Document` interface
### 4. DocumentEditModal.vue (new component)
Mini-modal with:
- Input text: document name (pre-filled)
- Select: document type (pre-filled)
- Buttons: Annuler / Sauvegarder
- On save: call `updateDocument()`, emit `updated` event
### 5. Document list display
Everywhere documents are listed (machine detail, composant edit, piece edit, product, site):
- Show type as a small badge next to the document name
- Add a pencil/edit button that opens `DocumentEditModal`
- On modal save: refresh the document in local state
## Migration of existing data
All existing documents classified by mimeType:
- `image/*``photo`
- `application/pdf``documentation`
- Everything else → `autre`
## Out of scope
- Custom user-defined types (table `document_types`) — can be added later
- Filtering documents by type in the UI — can be added later
- Bulk type change

View File

@@ -0,0 +1,88 @@
# Parc Machines — Améliorations UX
**Date** : 2026-03-23
**Scope** : 3 changements sur le frontend + 1 extension backend
---
## 1. Filtre sites multi-sélection par checkboxes
### Contexte
Le filtre site actuel est un `<select>` mono-sélection dans `machines/index.vue`.
L'utilisateur veut pouvoir sélectionner plusieurs sites simultanément.
### Design
- Remplacer le `<select>` par une rangée de checkboxes DaisyUI directement visibles dans la barre de filtre.
- Chaque site = une checkbox avec le nom du site.
- Quand **aucune** checkbox n'est cochée → toutes les machines s'affichent (équivalent "Tous les sites").
- Quand **une ou plusieurs** sont cochées → filtre sur ces sites uniquement.
### Changements techniques
**Fichier** : `Inventory_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.
- **Note** : le fichier utilise `<script setup>` sans `lang="ts"` — ne pas utiliser d'annotations TypeScript comme `Set<string>`.
- Template : remplacer le `<select>` par un `div` flex-wrap avec des checkboxes DaisyUI (`checkbox checkbox-sm`) + label pour chaque site.
- Computed `filteredMachines` : remplacer `machine.siteId === selectedSite` par `selectedSites.size === 0 || selectedSites.has(machine.siteId)`.
---
## 2. Tri alphabétique croissant
### Contexte
Les machines s'affichent dans l'ordre retourné par l'API, sans tri. L'utilisateur veut un tri alphabétique croissant par nom.
### Design
Ajouter un `.sort()` avec `localeCompare('fr')` à la fin du computed `filteredMachines`.
### Changements techniques
**Fichier** : `Inventory_frontend/app/pages/machines/index.vue`
- Dans le computed `filteredMachines`, ajouter avant le `return` :
```js
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
```
---
## 3. Recherche par référence dans les catalogues (Pièces, Composants, Produits)
### Contexte
Les placeholders des champs de recherche promettent "Nom ou référence…" mais le frontend n'envoie que `?name=xxx` à l'API. Le backend (API Platform SearchFilter) supporte `name` et `reference` en `ipartial`, mais combiner `?name=xxx&reference=xxx` produit un AND (les deux doivent matcher), pas un OR.
### Design
Créer une **Extension Doctrine** (`SearchByNameOrReferenceExtension`) qui intercepte un paramètre `?q=xxx` et ajoute une clause `WHERE name ILIKE %xxx% OR reference ILIKE %xxx%` à la requête. Côté frontend, remplacer `params.set('name', search)` par `params.set('q', search)`.
### Changements techniques
**Backend — Nouveau fichier** : `src/Doctrine/SearchByNameOrReferenceExtension.php`
- Implémente `QueryCollectionExtensionInterface`
- S'applique aux entités `Piece`, `Composant`, `Product`
- Lit le paramètre `q` depuis la requête HTTP
- Ajoute `LOWER(o.name) LIKE :searchQ OR LOWER(o.reference) LIKE :searchQ` avec paramètre `%{strtolower(q)}%`
- **Échappement LIKE** : les caractères `%` et `_` dans l'input utilisateur sont échappés via `addcslashes($q, '%_')` pour éviter des matchs trop larges
- **`reference` nullable** : les lignes avec `reference = NULL` ne matcheront pas (comportement SQL standard : `NULL LIKE x` = NULL = false), ce qui est le comportement attendu
- **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())`) :
- `Inventory_frontend/app/composables/usePieces.ts` → `params.set('q', search.trim())`
- `Inventory_frontend/app/composables/useComposants.ts` → idem
- `Inventory_frontend/app/composables/useProducts.ts` → idem
---
## Fichiers impactés (résumé)
| Fichier | Changement |
|---------|-----------|
| `Inventory_frontend/app/pages/machines/index.vue` | Checkboxes sites + tri alphabétique |
| `src/Doctrine/SearchByNameOrReferenceExtension.php` | **Nouveau** — Extension Doctrine OR search |
| `Inventory_frontend/app/composables/usePieces.ts` | `name` → `q` |
| `Inventory_frontend/app/composables/useComposants.ts` | `name` → `q` |
| `Inventory_frontend/app/composables/useProducts.ts` | `name` → `q` |
## 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.
- Aucun changement de placeholder — ils affichent déjà "Nom ou référence…".

View File

@@ -0,0 +1,312 @@
# Entity Versioning — Design Spec
**Date :** 2026-03-25
**Entites concernees :** Machine, Composant, Piece, Produit
**Approche :** Extension du systeme AuditLog existant
---
## Objectif
Permettre de consulter l'historique des versions numerotees (v1, v2, v3...) des entites principales et de restaurer n'importe quelle version anterieure, afin de ne jamais perdre de donnees.
---
## Regles metier
### Creation de version
- Chaque `create` ou `update` sur une entite incremente automatiquement le compteur `version` de l'entite
- Le numero de version est enregistre dans l'AuditLog correspondant (nouvelle colonne `version`)
### Restauration
- La restauration cree une **nouvelle version** (v+1) — on ne supprime jamais d'historique
- Le service `EntityVersionService::restore()` cree **manuellement** un AuditLog avec `action = "restore"` et le diff contient `restoredFromVersion: N`
- Important : le flush du restore declenche les AuditSubscribers, qui produiraient un `update` duplique. Pour eviter cela, l'entite porte un flag transitoire `$skipAudit = true` que les subscribers verifient
### Controle de squelette (Composant, Piece, Produit uniquement)
- Avant restauration, on compare le ModelType actuel avec celui du snapshot
- **Meme squelette (ModelType)** : restore complet — champs de base + slots + custom fields
- **Squelette different** : restore partiel — uniquement les champs de base (nom, description, reference, constructeurs, prix)
### Controle d'integrite
- Avant restauration, on verifie que toutes les entites liees dans le snapshot existent encore en base :
- **Composant** : pieces selectionnees dans les slots, produits, sous-composants, constructeurs
- **Piece** : produits selectionnes dans les slots, constructeurs
- **Produit** : constructeurs
- **Machine** : site, liens composants/pieces/produits (MachineComponentLink, MachinePieceLink, MachineProductLink)
- Les entites manquantes generent des **warnings** affiches a l'utilisateur
- Les slots avec des entites supprimees sont restaures **vides** (sans selection)
- Pour les custom field values : restauration par `fieldId` + entite parente (pas par ID de la CustomFieldValue elle-meme, car un sync ModelType peut recreer les CFV avec des IDs differents)
- Les controles d'integrite utilisent des requetes batch (`findBy(['id' => $ids])`) plutot que des requetes individuelles par slot
### Machines
- Pas de controle de squelette (pas de ModelType) : restauration toujours complete
- Controle d'integrite sur le site et les liens machine
- Machine n'a pas de champ `description` (contrairement aux autres entites)
### Permissions
- Consulter les versions : `ROLE_VIEWER`
- Restaurer une version : `ROLE_GESTIONNAIRE` et au-dessus
---
## Modifications backend
### 1. Colonne `version` sur AuditLog
```sql
ALTER TABLE audit_logs ADD COLUMN version INT DEFAULT NULL;
```
Nullable car les AuditLogs existants n'ont pas de version.
### 2. Colonne `version` sur Machine
```sql
ALTER TABLE machine ADD COLUMN version INT NOT NULL DEFAULT 1;
```
Les entites Composant, Piece, Produit ont deja cette colonne.
### 3. Enrichissement des snapshots
Les Audit Subscribers doivent inclure dans le `snapshot` :
**Composant :**
```json
{
"id": "cl...",
"name": "...",
"reference": "...",
"description": "...",
"prix": 100.00,
"typeComposant": { "id": "cl...", "name": "...", "code": "..." },
"product": { "id": "cl...", "name": "..." },
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"pieceSlots": [
{ "id": "cl...", "typePieceId": "cl...", "selectedPieceId": "cl...", "quantity": 1, "position": 0 }
],
"subcomponentSlots": [
{ "id": "cl...", "alias": "...", "familyCode": "...", "typeComposantId": "cl...", "selectedComposantId": "cl...", "position": 0 }
],
"productSlots": [
{ "id": "cl...", "typeProductId": "cl...", "selectedProductId": "cl...", "familyCode": "...", "position": 0 }
],
"version": 3
}
```
**Piece :**
```json
{
"id": "cl...",
"name": "...",
"reference": "...",
"description": "...",
"prix": 50.00,
"typePiece": { "id": "cl...", "name": "...", "code": "..." },
"product": { "id": "cl...", "name": "..." },
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"productSlots": [
{ "id": "cl...", "typeProductId": "cl...", "selectedProductId": "cl...", "familyCode": "...", "position": 0 }
],
"version": 2
}
```
**Produit :**
```json
{
"id": "cl...",
"name": "...",
"reference": "...",
"supplierPrice": 25.00,
"typeProduct": { "id": "cl...", "name": "...", "code": "..." },
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"version": 1
}
```
**Machine :**
```json
{
"id": "cl...",
"name": "...",
"reference": "...",
"prix": 1500.00,
"site": { "id": "cl...", "name": "..." },
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"version": 4
}
```
### 4. Incrementation automatique de la version
Dans chaque Audit Subscriber, a chaque `create`/`update` :
1. Appeler `$entity->incrementVersion()`
2. Ecrire `$auditLog->setVersion($entity->getVersion())`
Pour Machine, ajouter la methode `incrementVersion()` et la propriete `version` a l'entite.
### 5. Nouveaux endpoints — `EntityVersionController`
| Methode | Route | Description | Role |
|---------|-------|-------------|------|
| GET | `/api/{entity}/{id}/versions` | Liste des versions | ROLE_VIEWER |
| GET | `/api/{entity}/{id}/versions/{version}/preview` | Preview + controles avant restore | ROLE_GESTIONNAIRE |
| POST | `/api/{entity}/{id}/versions/{version}/restore` | Execute la restauration | ROLE_GESTIONNAIRE |
`{entity}` = `machines`, `composants`, `pieces`, `products`
**GET versions — Response :**
```json
{
"items": [
{
"version": 3,
"action": "update",
"createdAt": "2026-03-25T14:30:00+00:00",
"actor": { "id": "cl...", "label": "Jean Dupont" },
"diff": { "name": { "from": "Ancien", "to": "Nouveau" } }
}
],
"total": 3
}
```
**GET preview — Response :**
```json
{
"version": 2,
"restoreMode": "full",
"diff": {
"name": { "current": "Nouveau", "restored": "Ancien" },
"reference": { "current": "REF-002", "restored": "REF-001" }
},
"warnings": [
{
"field": "pieceSlots[0].selectedPieceId",
"message": "La piece 'Roulement XY' (cl...) n'existe plus. Le slot sera restaure vide.",
"missingEntityId": "cl...",
"missingEntityName": "Roulement XY"
}
],
"snapshot": { }
}
```
`restoreMode` : `"full"` (meme squelette) ou `"partial"` (squelette different, champs de base uniquement).
**POST restore — Response :**
```json
{
"success": true,
"newVersion": 6,
"restoredFromVersion": 2,
"restoreMode": "full",
"warnings": []
}
```
### 6. Service `EntityVersionService`
Service centralise pour la logique de versioning :
- `getVersions(string $entityType, string $entityId): array` — liste des versions depuis AuditLog
- `getRestorePreview(string $entityType, string $entityId, int $version): array` — controles + diff
- `restore(string $entityType, string $entityId, int $version): array` — execution du restore
Methodes internes :
- `checkSkeletonCompatibility(object $entity, array $snapshot): string` — retourne `"full"` ou `"partial"`
- `checkIntegrity(string $entityType, array $snapshot): array` — retourne les warnings
- `applyRestore(object $entity, array $snapshot, string $mode): void` — applique les changements
---
## Modifications frontend
### 1. Composant `EntityVersionList.vue`
Composant reutilisable affiche dans un onglet "Versions" sur les pages de detail.
**Props :**
- `entityType: 'machines' | 'composants' | 'pieces' | 'products'`
- `entityId: string`
**Affichage :**
- Tableau : version, date, auteur, action, diff resume
- Badge "Actuelle" sur la version la plus recente
- Bouton "Restaurer" sur chaque ligne (sauf version actuelle), visible uniquement pour ROLE_GESTIONNAIRE+
### 2. Composant `VersionRestoreModal.vue`
Modal de confirmation avec preview.
**Props :**
- `entityType`, `entityId`, `version` (cible)
- `previewData` (resultat du GET preview)
**Affichage :**
- Indicateur de mode : "Restauration complete" ou "Restauration partielle"
- Diff visuel : champs qui changent (valeur actuelle -> valeur restauree)
- Warnings en alerte orange pour les entites manquantes
- Boutons "Confirmer la restauration" / "Annuler"
### 3. Composable `useEntityVersions.ts`
```typescript
interface Deps {
entityType: MaybeRef<string>
entityId: MaybeRef<string>
}
export function useEntityVersions(deps: Deps) {
// fetchVersions() — GET /api/{entity}/{id}/versions
// fetchPreview(version) — GET /api/{entity}/{id}/versions/{version}/preview
// restore(version) — POST /api/{entity}/{id}/versions/{version}/restore
}
```
### 4. Integration dans les pages de detail
Ajouter un onglet "Versions" dans les pages :
- `pages/machines/[id].vue`
- `pages/composants/[id].vue`
- `pages/pieces/[id].vue`
- `pages/products/[id].vue`
L'onglet affiche `EntityVersionList` qui gere l'ouverture de `VersionRestoreModal`.
---
## Migration
Une seule migration PostgreSQL :
```sql
-- Colonne version sur audit_logs
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS version INT DEFAULT NULL;
-- Colonne version sur machine
ALTER TABLE machine ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
-- Index pour requetes par version
CREATE INDEX IF NOT EXISTS idx_audit_entity_version ON audit_logs (entity_type, entity_id, version);
```
---
## Ce qui change (breaking)
- **Piece snapshot** : le champ legacy `productIds` (ancien JSON) est remplace par `productSlots` (tables normalisees). Les anciens AuditLogs conservent `productIds` dans leur snapshot mais les nouveaux ne l'auront plus. Le restore utilise `productSlots` exclusivement.
## Ce qui ne change PAS
- L'onglet/page d'historique existant (`EntityHistoryController`) reste inchange
- Les AuditLogs existants (sans version) continuent de fonctionner
- Le mecanisme d'audit automatique via les Subscribers reste identique, juste enrichi
- Les documents ne sont pas versionnes (hors scope)

View File

@@ -0,0 +1,117 @@
# Machine : Bouton Save Unique + Versioning des Liens
**Date :** 2026-03-26
**Statut :** Approuvé
## Contexte
La page machine utilise actuellement un auto-save au blur pour chaque champ (info, custom fields, constructeurs). Les pages composant/pièce/produit utilisent un bouton unique "Enregistrer les modifications" en bas du formulaire. L'objectif est d'aligner la page machine sur ce pattern.
De plus, les ajouts/suppressions de liens composant/pièce/produit sur une machine ne sont pas tracés dans le versioning. Ils doivent l'être.
## Volet 1 : Bouton Save Unique
### Comportement cible
- En mode édition, tous les champs (info machine, custom field values, custom field definitions, constructeurs) sont modifiés localement sans appel API.
- Un bouton "Enregistrer les modifications" en bas du formulaire sauvegarde tout d'un coup.
- Un bouton "Annuler" réinitialise l'état local et sort du mode édition.
- Les documents restent en upload/suppression immédiate (inchangé).
- Les ajouts/suppressions de liens composant/pièce/produit restent immédiats via modales (inchangé).
### Changements frontend
#### MachineInfoCard.vue
- Supprimer les `@blur``$emit('blur-field')` sur les inputs (nom, référence)
- Supprimer le `@change` qui émet `blur-field` sur le select site
- Supprimer les `@blur``$emit('update-custom-field', field)` sur tous les champs custom
- Conserver `@input` / `@update:*` / `set-custom-field-value` pour la mise à jour de l'état local
- Le `MachineCustomFieldDefEditor` perd son bouton save propre : l'état est collecté au submit global
#### machine/[id].vue
- Supprimer le handler `@blur-field`
- Supprimer le handler `@update-custom-field`
- `@update:constructeur-ids` met à jour l'état local sans save
- Ajouter le bloc boutons en bas (pattern identique à component/[id]/index.vue) :
- "Annuler" (btn-ghost) → `cancelEdition()` : réinitialise depuis `machine.value` + sort du mode édition
- "Enregistrer les modifications" (btn-primary, disabled si `!canSubmit`) → `submitEdition()`
#### useMachineDetailData.ts
- Exposer `saving` ref
- Exposer `submitEdition()` :
1. `updateMachineInfo()` — PATCH machine (nom, ref, site, constructeurs)
2. Batch save custom field values (tous les `visibleMachineCustomFields` avec valeur)
3. Save custom field definitions si modifiées (`fieldDefs.saveDefinitions()`)
4. `loadMachineData()` pour recharger
5. Sortie du mode édition + toast succès
- Exposer `cancelEdition()` :
1. `initMachineFields()` — réinitialise nom, ref, site, constructeurs depuis `machine.value`
2. `syncMachineCustomFields()` — réinitialise les custom fields
3. Sort du mode édition
#### useMachineDetailUpdates.ts
- `handleMachineConstructeurChange` ne déclenche plus `updateMachineInfo()`, met juste à jour le ref local
#### useMachineDetailCustomFields.ts
- `updateMachineCustomField` n'est plus appelé au blur — sera appelé en batch par `submitEdition()`
- Ajouter méthode `saveAllMachineCustomFields()` qui itère sur les champs visibles et sauvegarde ceux avec valeur
### Validation (`canSubmit`)
- Machine existe
- Nom non vide
- Pas en cours de sauvegarde (`!saving.value`)
- `canEdit` est true
## Volet 2 : Versioning des Liens Machine
### Comportement cible
Quand un composant, pièce ou produit est ajouté ou supprimé d'une machine, cela doit :
1. Incrémenter la `version` de la Machine
2. Créer une entrée `AuditLog` avec diff et snapshot
### Changements backend
#### MachineAuditSubscriber — enrichir le snapshot
Ajouter au snapshot machine les liens :
```php
'componentLinks' => array_map(fn($link) => [
'id' => $link->getId(),
'composantId' => $link->getComposant()->getId(),
'composantName' => $link->getComposant()->getName(),
], $entity->getComponentLinks()->toArray()),
'pieceLinks' => [...],
'productLinks' => [...],
```
#### Nouveau subscriber ou service : MachineLinkAuditService
Écouter les événements Doctrine `postPersist` et `postRemove` sur les 3 entités link.
Quand un lien est créé/supprimé :
1. Récupérer la Machine parente
2. Incrémenter `$machine->incrementVersion()`
3. Créer un `AuditLog` :
- `entityType: 'machine'`
- `entityId: $machine->getId()`
- `action: 'update'`
- `diff: { addedComponent: {id, name} }` ou `{ removedPiece: {id, name} }`
- `snapshot:` snapshot complet de la machine (avec liens mis à jour)
- `version:` nouvelle version
### Labels pour le diff (frontend)
Ajouter au `historyFieldLabels` de la page machine :
```js
addedComponent: 'Composant ajouté',
removedComponent: 'Composant supprimé',
addedPiece: 'Pièce ajoutée',
removedPiece: 'Pièce supprimée',
addedProduct: 'Produit ajouté',
removedProduct: 'Produit supprimé',
```
## Ce qui ne change PAS
- Upload/suppression de documents (immédiat)
- Pattern read/edit toggle dans le header
- L'affichage des sections composants/pièces/produits
- Les modales d'ajout/suppression de liens (restent immédiates)
- Le versioning des autres entités (composant, pièce, produit)

View File

@@ -0,0 +1,60 @@
# Spec : Formula Builder interactif pour la référence auto
**Date** : 2026-03-31
**Scope** : Frontend uniquement (pas de changement backend)
**Fichier impacté** : `Inventory_frontend/app/components/model-types/ModelTypeForm.vue`
## Problème
L'utilisateur doit taper manuellement les noms exacts des custom fields dans la formule (`{serie}{diametre}{type}`) et re-lister les champs requis séparés par des virgules. C'est sujet aux erreurs de typo et peu ergonomique.
## Solution
Remplacer la section "Génération de référence automatique" du `ModelTypeForm` par un formula builder interactif.
### Composants UI
#### 1. Chips de champs disponibles
- Afficher une rangée de boutons-chips avec les noms des custom fields définis dans `pieceStructure.customFields`
- Cliquer sur un chip insère `{nom_du_champ}` dans l'input formule à la position du curseur
- Si `pieceStructure.customFields` est vide, afficher un message "Aucun champ personnalisé défini"
#### 2. Input formule
- Input texte classique (comme aujourd'hui) mais avec les chips comme aide à la saisie
- L'utilisateur peut aussi taper du texte libre (séparateurs `-`, `/`, préfixes `SNU `, etc.)
- Le format stocké reste `{nom_du_champ}` — aucun changement de format backend
#### 3. Suppression du champ "Champs requis"
- Le champ `requiredFieldsForReference` est calculé automatiquement au submit en extrayant tous les `{...}` de la formule
- Suppression de l'input "Champs requis" et de la variable `requiredFieldsInput`
- La logique : tous les champs présents dans la formule sont requis. Si un champ n'a pas de valeur → pas de référence générée
#### 4. Aperçu live
- Conserver l'aperçu existant mais l'améliorer : remplacer les placeholders par des valeurs d'exemple en majuscules
- Exemples par type de champ : `text``VALEUR`, `number``123`, `select``OPTION`, `boolean``OUI`, `date``2026-01-01`
### Comportement
- **Insert au curseur** : quand l'utilisateur clique un chip, le placeholder est inséré à `selectionStart` de l'input, pas à la fin
- **Formule vide** : si la formule est vide, pas de référence auto (comportement actuel conservé)
- **Readonly** : les chips sont désactivés en mode readonly (comme l'input)
- **Pas de custom fields** : si aucun champ n'est défini dans la structure, la section reste visible mais les chips sont remplacés par un message informatif. L'utilisateur peut quand même taper une formule manuellement (cas edge)
### Format de sortie (inchangé)
```typescript
{
referenceFormula: "SNU {serie}-{diametre}/{type}" | null,
requiredFieldsForReference: ["serie", "diametre", "type"] | null // auto-calculé
}
```
### Pas de changement
- Backend (`ReferenceAutoGenerator`, `ReferenceAutoSubscriber`, entités) : aucun changement
- Format de stockage de la formule : identique (`{placeholder}` strings)
- API : identique

View File

@@ -0,0 +1,82 @@
# Références Fournisseur par Item — Design Spec
**Date :** 2026-03-31
**Statut :** Validé
## Contexte
Chaque entité (Machine, Pièce, Composant, Produit) a un champ `reference` générique et une relation ManyToMany avec `Constructeur`. Il n'existe aucun moyen de stocker une référence spécifique par fournisseur — si un item est vendu par 3 fournisseurs avec 3 références différentes, on ne peut en stocker qu'une seule.
## Objectif
Permettre de stocker une référence fournisseur (`supplierReference`) par couple (item, constructeur). Le champ `reference` existant reste inchangé comme référence interne. Le champ `supplierPrice` sur Product reste inchangé.
## Design
### Approche retenue : conversion ManyToMany → entités pivot
Remplacer les 4 tables de jointure simples (`_MachineConstructeurs`, `_PieceConstructeurs`, `_ComposantConstructeurs`, `_ProductConstructeurs`) par de vraies entités Doctrine Link, suivant le pattern existant (`MachinePieceLink`, `MachineComponentLink`, etc.).
### Nouvelles entités
| Entité | Table | FK item | FK constructeur | Champs extra |
|--------|-------|---------|-----------------|--------------|
| `MachineConstructeurLink` | `machine_constructeur_links` | `machineId``Machine` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
| `PieceConstructeurLink` | `piece_constructeur_links` | `pieceId``Piece` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
| `ComposantConstructeurLink` | `composant_constructeur_links` | `composantId``Composant` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
| `ProductConstructeurLink` | `product_constructeur_links` | `productId``Product` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
### Structure de chaque entité
Chaque entité suit le pattern `MachinePieceLink` :
- `CuidEntityTrait` pour l'ID (string, 36 chars)
- `#[ORM\HasLifecycleCallbacks]` avec `createdAt` / `updatedAt`
- Contrainte unique sur `(item_id, constructeur_id)` via `#[ORM\UniqueConstraint]`
- `#[ApiResource]` avec opérations CRUD complètes
- Sécurité : `ROLE_VIEWER` pour lecture, `ROLE_GESTIONNAIRE` pour écriture
- `ManyToOne` vers l'item (onDelete CASCADE)
- `ManyToOne` vers `Constructeur` (onDelete CASCADE)
- Champ `supplierReference` (string 255, nullable)
### Modifications sur les entités existantes
#### Machine, Pièce, Composant, Produit
- Supprimer la propriété `ManyToMany` `constructeurs` et ses getters/setters/add/remove
- Ajouter une propriété `OneToMany` `constructeurLinks` vers le Link correspondant
- Getter `getConstructeurLinks(): Collection`
#### Constructeur
- Supprimer les 4 propriétés `ManyToMany` (`machines`, `composants`, `pieces`, `products`) et leurs getters/setters
- Ajouter 4 propriétés `OneToMany` vers les Links correspondants
### Migration SQL
1. Créer les 4 nouvelles tables avec colonnes `id`, `machineId`/`pieceId`/etc., `constructeurId`, `supplierReference`, `createdAt`, `updatedAt`
2. Ajouter les contraintes uniques
3. Migrer les données des anciennes tables de jointure vers les nouvelles (génération CUID pour chaque ligne, `supplierReference` = NULL)
4. Supprimer les anciennes tables de jointure (`_MachineConstructeurs`, `_PieceConstructeurs`, `_ComposantConstructeurs`, `_ProductConstructeurs`)
### API
Endpoints API Platform auto-générés pour chaque Link :
- `GET /api/machine_constructeur_links` — liste (filtrable par machine, constructeur)
- `GET /api/machine_constructeur_links/{id}` — détail
- `POST /api/machine_constructeur_links` — créer un lien avec référence
- `PATCH /api/machine_constructeur_links/{id}` — modifier la référence
- `DELETE /api/machine_constructeur_links/{id}` — supprimer le lien
Idem pour les 3 autres types.
### Frontend
Les pages détail/édition qui affichent les constructeurs devront être adaptées pour :
- Afficher la `supplierReference` à côté de chaque constructeur
- Permettre l'édition de la référence fournisseur lors de l'ajout/modification d'un constructeur
- Utiliser les endpoints `*ConstructeurLink` au lieu de la collection `constructeurs`
### Hors périmètre
- Migration de `supplierPrice` de Product vers le Link (explicitement exclu)
- Modification du champ `reference` existant sur les entités
- Référence auto (`referenceAuto`) sur Pièce/Composant — non impactée

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

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

@@ -0,0 +1,63 @@
<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"
/>
<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,270 @@
<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 handleDelete = async (commentId: string) => {
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,47 @@
<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')"
/>
</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'])
</script>

View File

@@ -0,0 +1,465 @@
<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 bg-base-200 rounded-lg cursor-pointer" @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 text-base-content truncate">
{{ component.name }}
</h3>
<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" 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"
@field-blur="updateComponentCustomField"
/>
<!-- Documents -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ 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"
/>
</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)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
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 {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
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'])
// --- 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 {
displayedCustomFields,
updateCustomField: updateComponentCustomField,
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
// --- 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,51 @@
<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>
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
Retour au catalogue
</button>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
const router = useRouter()
const props = defineProps<{
title: string
subtitle?: string
isEditMode: boolean
canEdit: boolean
backLink: string
}>()
defineEmits<{
'toggle-edit': []
}>()
function goBack() {
if (window.history.length > 1) {
router.back()
}
else {
navigateTo(props.backLink)
}
}
</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,578 @@
<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 bg-base-200 rounded-lg">
<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">
{{ pieceData.name }}
<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" 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"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
<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>
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 {
resolveFieldId,
resolveFieldReadOnly,
} from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
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'])
// --- 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 {
displayedCustomFields,
updateCustomField,
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
// --- 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 (resolveFieldReadOnly(field)) return
const fieldValueId = resolveFieldId(field)
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 || field?.customField?.id || 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,186 @@
<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>
<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,416 @@
<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>
<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,114 @@
<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 shadow-md px-3 py-2 text-sm"
: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>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
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) => {
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;
}
</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,173 @@
<template>
<div
v-if="fields.length"
class="mt-4 pt-4 border-t border-base-200"
>
<h5 class="text-sm font-medium text-base-content/80 mb-3">
Champs personnalisés
</h5>
<div :class="layoutClass">
<div
v-for="(field, index) in fields"
:key="resolveFieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{
resolveFieldName(field)
}}</span>
<span
v-if="resolveFieldRequired(field)"
class="label-text-alt text-error"
>*</span>
</label>
<!-- Mode édition -->
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
<!-- Champ de type TEXT -->
<input
v-if="resolveFieldType(field) === 'text'"
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
<!-- Champ de type NUMBER -->
<input
v-else-if="resolveFieldType(field) === 'number'"
:value="field.value ?? ''"
type="number"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
<!-- Champ de type SELECT -->
<select
v-else-if="resolveFieldType(field) === 'select'"
:value="field.value ?? ''"
class="select select-bordered select-sm"
:required="resolveFieldRequired(field)"
@change="onInput(field, ($event.target as HTMLSelectElement).value)"
@blur="onBlur(field)"
>
<option value="">
Sélectionner...
</option>
<option
v-for="option in resolveFieldOptions(field)"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<!-- Champ de type BOOLEAN -->
<div
v-else-if="resolveFieldType(field) === '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="resolveFieldType(field) === 'date'"
:value="field.value ?? ''"
type="date"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="onInput(field, ($event.target as HTMLInputElement).value)"
@blur="onBlur(field)"
>
<!-- Champ de type TEXTAREA -->
<textarea
v-else-if="resolveFieldType(field) === 'textarea'"
:value="field.value ?? ''"
class="textarea textarea-bordered textarea-sm"
:required="resolveFieldRequired(field)"
@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="resolveFieldRequired(field)"
@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">
{{ formatFieldDisplayValue(field) }}
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
resolveFieldKey,
resolveFieldName,
resolveFieldType,
resolveFieldOptions,
resolveFieldRequired,
resolveFieldReadOnly,
formatFieldDisplayValue,
} from '~/shared/utils/entityCustomFieldLogic'
const props = defineProps<{
fields: any[]
isEditMode: boolean
columns?: 1 | 2
}>()
const emit = defineEmits<{
'field-input': [field: any, value: string]
'field-blur': [field: any]
}>()
const layoutClass = computed(() =>
props.columns === 2
? 'grid grid-cols-1 md:grid-cols-2 gap-4'
: 'space-y-3',
)
function onInput(field: any, value: string) {
field.value = value
emit('field-input', field, value)
}
function onBooleanChange(field: any, checked: boolean) {
const value = checked ? 'true' : 'false'
field.value = value
emit('field-input', field, value)
emit('field-blur', field)
}
function onBlur(field: any) {
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/customFieldFormUtils'
defineProps<{
fields: CustomFieldInput[]
disabled?: boolean
}>()
</script>

View File

@@ -0,0 +1,308 @@
<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]">
<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 },
]"
>
<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
}>(), {
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,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,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>

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