92 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:58:41 +01:00
Matthieu
032b3b33c9 docs(changelog) : add v1.8.1 release notes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:39:01 +01:00
138 changed files with 15633 additions and 9813 deletions

View File

@@ -1,15 +1,20 @@
<template> <template>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100"> <div class="min-h-screen flex flex-col bg-base-200/40">
<LayoutAppNavbar <!-- 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" @open-settings="displaySettingsOpen = true"
@logout="handleLogout" @logout="handleLogout"
/> />
<NuxtPage /> <main class="flex-1">
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />
</main>
<ToastContainer /> <ToastContainer />
<CommonConfirmModal /> <ConfirmModal />
<DisplaySettings <DisplaySettings
:is-open="displaySettingsOpen" :is-open="displaySettingsOpen"
@@ -17,11 +22,17 @@
@update-settings="handleSettingsUpdate" @update-settings="handleSettingsUpdate"
/> />
<footer class="footer p-4 bg-neutral text-neutral-content"> <footer class="border-t border-base-300/50 bg-base-100/60 backdrop-blur-sm">
<div class="items-center grid-flow-col"> <div class="container mx-auto flex items-center justify-between px-6 py-3">
<p> <p class="text-xs text-base-content/40 font-medium tracking-wide">
@Malio 2025 · <NuxtLink to="/changelog" class="link link-hover">v{{ appVersion }}</NuxtLink> &copy; Malio {{ new Date().getFullYear() }}
</p> </p>
<NuxtLink
to="/changelog"
class="text-xs text-base-content/40 hover:text-primary transition-colors font-medium"
>
v{{ appVersion }}
</NuxtLink>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -1,55 +1,136 @@
/* ─── 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"; @import "tailwindcss";
@plugin "daisyui"; @plugin "daisyui";
/* ─── Theme ─── */
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "mytheme"; name: "mytheme";
default: true; /* set as default */ default: true;
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */ prefersdark: false;
color-scheme: light; /* color of browser-provided UI */ color-scheme: light;
/* #FBFAFA — gris clair */ /* Surfaces — warm gray with a hint of blue */
--color-base-100: oklch(98% 0.003 0); --color-base-100: oklch(98.5% 0.004 260);
--color-base-200: oklch(94% 0.01 262); --color-base-200: oklch(95% 0.008 260);
--color-base-300: oklch(90% 0.02 262); --color-base-300: oklch(91% 0.015 260);
--color-base-content: oklch(20% 0.03 262); --color-base-content: oklch(22% 0.025 260);
/* #304998 — bleu Malio */
--color-primary: oklch(37% 0.15 262); /* Primary — Malio blue, slightly richer */
--color-primary: oklch(40% 0.16 262);
--color-primary-content: oklch(98% 0.005 262); --color-primary-content: oklch(98% 0.005 262);
/* #A5ACD0 — lavande */
--color-secondary: oklch(75% 0.055 270); /* Secondary — refined lavender */
--color-secondary-content: oklch(20% 0.03 270); --color-secondary: oklch(72% 0.06 275);
/* #ED8521 — orange */ --color-secondary-content: oklch(22% 0.03 275);
--color-accent: oklch(71% 0.17 58);
--color-accent-content: oklch(98% 0.005 58); /* Accent — warm amber-orange */
/* neutral dérivé du bleu Malio */ --color-accent: oklch(72% 0.17 55);
--color-neutral: oklch(37% 0.08 262); --color-accent-content: oklch(20% 0.04 55);
--color-neutral-content: oklch(98% 0.005 262);
--color-info: oklch(55% 0.12 262); /* Neutral — deep slate */
--color-info-content: oklch(98% 0.005 262); --color-neutral: oklch(28% 0.04 260);
--color-success: oklch(65% 0.2 145); --color-neutral-content: oklch(95% 0.005 260);
--color-success-content: oklch(98% 0.005 145);
/* 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: oklch(78% 0.15 70);
--color-warning-content: oklch(20% 0.05 70); --color-warning-content: oklch(22% 0.05 70);
--color-error: oklch(60% 0.25 25); --color-error: oklch(58% 0.22 25);
--color-error-content: oklch(98% 0.005 25); --color-error-content: oklch(98% 0.005 25);
/* border radius */ /* Geometry */
--radius-selector: 1rem; --radius-selector: 0.75rem;
--radius-field: 0.25rem; --radius-field: 0.375rem;
--radius-box: 0.5rem; --radius-box: 0.625rem;
/* base sizes */
--size-selector: 0.25rem; --size-selector: 0.25rem;
--size-field: 0.25rem; --size-field: 0.25rem;
/* border size */
--border: 1px; --border: 1px;
/* effects */
--depth: 1; --depth: 1;
--noise: 0; --noise: 0;
} }
/* Styles pour l'accessibilité et les paramètres d'affichage */ @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 { :root {
--spacing-xs: 0.5rem; --spacing-xs: 0.5rem;
--spacing-sm: 0.75rem; --spacing-sm: 0.75rem;
@@ -58,7 +139,6 @@
--spacing-xl: 2rem; --spacing-xl: 2rem;
} }
/* Densité compacte */
.density-compact { .density-compact {
--spacing-xs: 0.25rem; --spacing-xs: 0.25rem;
--spacing-sm: 0.5rem; --spacing-sm: 0.5rem;
@@ -67,7 +147,6 @@
--spacing-xl: 1.25rem; --spacing-xl: 1.25rem;
} }
/* Densité confortable (défaut) */
.density-comfortable { .density-comfortable {
--spacing-xs: 0.5rem; --spacing-xs: 0.5rem;
--spacing-sm: 0.75rem; --spacing-sm: 0.75rem;
@@ -76,7 +155,6 @@
--spacing-xl: 2rem; --spacing-xl: 2rem;
} }
/* Densité espacée */
.density-spacious { .density-spacious {
--spacing-xs: 0.75rem; --spacing-xs: 0.75rem;
--spacing-sm: 1rem; --spacing-sm: 1rem;
@@ -85,251 +163,200 @@
--spacing-xl: 3rem; --spacing-xl: 3rem;
} }
/* Contraste élevé avec DaisyUI */ /* ─── High contrast mode ─── */
.contrast-high .btn { .contrast-high .btn { @apply border-2; }
@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; }
.contrast-high .input { /* ─── Accessibility ─── */
@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;
}
/* Amélioration de l'accessibilité */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *, *::before, *::after {
*::before,
*::after {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* Focus visible pour l'accessibilité */
*:focus-visible { *:focus-visible {
outline: 2px solid #304998; outline: 2px solid oklch(40% 0.16 262);
outline-offset: 2px; outline-offset: 2px;
} }
/* Styles pour les boutons de paramètres */ /* ─── 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 { .btn-circle {
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }
.btn-circle:hover { .btn-circle:hover {
transform: scale(1.05); transform: scale(1.05);
} }
.btn-circle:active { .btn-circle:active {
transform: scale(0.95); transform: scale(0.95);
} }
/* Animation pour le modal */ /* ─── 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 { .modal {
transition: opacity 0.3s ease-in-out; transition: opacity 0.25s ease;
font-size: 100% !important; /* Force la taille normale pour le modal */
transform: none !important; /* Empêche les transformations */
scale: 1 !important; /* Force l'échelle à 1 */
} }
.modal.modal-open {
animation: modalFadeIn 0.3s ease-in-out;
}
/* S'assurer que le contenu du modal garde une taille normale */
.modal-box { .modal-box {
font-size: 100% !important; font-family: var(--font-body);
transform: none !important; border-radius: 0.75rem;
scale: 1 !important; border: 1px solid oklch(91% 0.015 260 / 0.5);
width: auto !important;
max-width: 500px !important;
} }
.modal .form-control { @keyframes modalSlideUp {
font-size: 100% !important; from { opacity: 0; transform: translateY(0.5rem); }
transform: none !important; to { opacity: 1; transform: translateY(0); }
} }
.modal .btn { .modal.modal-open .modal-box {
font-size: 100% !important; animation: modalSlideUp 0.25s ease-out;
transform: none !important;
padding: 0.5rem 1rem !important;
height: auto !important;
min-height: 2.5rem !important;
} }
.modal .input { /* ─── Page transitions ─── */
font-size: 100% !important; .page-enter-active {
transform: none !important; transition: opacity 0.2s ease, transform 0.2s ease;
height: auto !important; }
min-height: 2.5rem !important; .page-leave-active {
transition: opacity 0.15s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(4px);
}
.page-leave-to {
opacity: 0;
} }
.modal .select { /* ─── Scrollbar styling ─── */
font-size: 100% !important; ::-webkit-scrollbar {
transform: none !important; width: 6px;
height: auto !important; height: 6px;
min-height: 2.5rem !important; }
::-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);
} }
.modal .textarea { /* ─── Readability ─── */
font-size: 100% !important; .text-sm { line-height: 1.5; }
transform: none !important; .text-xs { line-height: 1.4; }
min-height: 4rem !important;
}
.modal .range { /* ─── Adaptive spacing ─── */
font-size: 100% !important; .p-1 { padding: var(--spacing-xs); }
transform: none !important; .p-2 { padding: var(--spacing-sm); }
height: auto !important; .p-3 { padding: var(--spacing-md); }
min-height: 1.5rem !important; .p-4 { padding: var(--spacing-lg); }
} .p-5 { padding: var(--spacing-xl); }
.modal .label { .m-1 { margin: var(--spacing-xs); }
font-size: 100% !important; .m-2 { margin: var(--spacing-sm); }
transform: none !important; .m-3 { margin: var(--spacing-md); }
} .m-4 { margin: var(--spacing-lg); }
.m-5 { margin: var(--spacing-xl); }
.modal .label-text { .gap-1 { gap: var(--spacing-xs); }
font-size: 100% !important; .gap-2 { gap: var(--spacing-sm); }
transform: none !important; .gap-3 { gap: var(--spacing-md); }
} .gap-4 { gap: var(--spacing-lg); }
.gap-5 { gap: var(--spacing-xl); }
.modal .label-text-alt {
font-size: 100% !important;
transform: none !important;
}
.modal .checkbox {
font-size: 100% !important;
transform: none !important;
width: 1rem !important;
height: 1rem !important;
}
.modal .text-xs {
font-size: 0.75rem !important;
transform: none !important;
}
.modal .text-sm {
font-size: 0.875rem !important;
transform: none !important;
}
.modal .text-lg {
font-size: 1.125rem !important;
transform: none !important;
}
.modal .font-bold {
font-weight: 700 !important;
transform: none !important;
}
.modal .font-medium {
font-weight: 500 !important;
transform: none !important;
}
/* Empêcher les héritages de taille */
.modal * {
font-size: inherit !important;
transform: none !important;
scale: 1 !important;
}
@keyframes modalFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Styles pour les contrôles de zoom */
.range {
transition: all 0.2s ease-in-out;
}
.range::-webkit-slider-thumb {
transition: all 0.2s ease-in-out;
}
.range::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
/* Amélioration de la lisibilité */
.text-sm {
line-height: 1.5;
}
.text-xs {
line-height: 1.4;
}
/* Espacement adaptatif */
.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 { @layer components {
.form-control .label { .form-control .label {
@@ -337,7 +364,6 @@
padding-bottom: 0; padding-bottom: 0;
margin-right: 15px; margin-right: 15px;
} }
.form-control .label + * { .form-control .label + * {
margin-top: var(--spacing-xs); 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

@@ -19,24 +19,55 @@
</div> </div>
<!-- Formulaire d'ajout --> <!-- Formulaire d'ajout -->
<div class="flex gap-2"> <div class="space-y-2">
<textarea <div class="flex gap-2">
v-model="newContent" <textarea
class="textarea textarea-bordered flex-1 text-sm" v-model="newContent"
rows="2" class="textarea textarea-bordered flex-1 text-sm"
placeholder="Ajouter un commentaire..." rows="2"
:disabled="submitting" placeholder="Ajouter un commentaire..."
@keydown.ctrl.enter="handleSubmit" :disabled="submitting"
/> @keydown.ctrl.enter="handleSubmit"
<button />
type="button" <div class="flex flex-col gap-1 self-end">
class="btn btn-primary btn-sm self-end" <label
:disabled="!newContent.trim() || submitting" class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
@click="handleSubmit" data-tip="Joindre des fichiers"
> >
<span v-if="submitting" class="loading loading-spinner loading-xs" /> <IconLucidePaperclip class="w-4 h-4" />
<IconLucideSend v-else class="w-4 h-4" /> <input
</button> 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> </div>
<!-- Liste des commentaires ouverts --> <!-- Liste des commentaires ouverts -->
@@ -57,6 +88,8 @@
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex-1"> <div class="flex-1">
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p> <p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
<!-- Documents attachés -->
<CommentDocumentList :documents="getDocuments(comment)" />
</div> </div>
</div> </div>
<div class="flex items-center justify-between text-xs text-base-content/60"> <div class="flex items-center justify-between text-xs text-base-content/60">
@@ -97,6 +130,8 @@
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1" class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
> >
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p> <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"> <div class="flex items-center justify-between text-xs text-base-content/50">
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span> <span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
<span v-if="comment.resolvedByName"> <span v-if="comment.resolvedByName">
@@ -110,12 +145,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useComments, type Comment } from '~/composables/useComments' import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
import { usePermissions } from '~/composables/usePermissions' import { usePermissions } from '~/composables/usePermissions'
import CommentDocumentList from '~/components/CommentDocumentList.vue'
import IconLucideMessageSquare from '~icons/lucide/message-square' import IconLucideMessageSquare from '~icons/lucide/message-square'
import IconLucideSend from '~icons/lucide/send' import IconLucideSend from '~icons/lucide/send'
import IconLucideCheck from '~icons/lucide/check' import IconLucideCheck from '~icons/lucide/check'
import IconLucideTrash2 from '~icons/lucide/trash-2' 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<{ const props = defineProps<{
entityType: string entityType: string
@@ -138,6 +177,11 @@ const newContent = ref('')
const submitting = ref(false) const submitting = ref(false)
const loadingComments = ref(false) const loadingComments = ref(false)
const showResolvedList = 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(() => const openComments = computed(() =>
comments.value.filter(c => c.status === 'open'), comments.value.filter(c => c.status === 'open'),
@@ -159,6 +203,18 @@ const formatCommentDate = (dateStr: string): string => {
}).format(date) }).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 () => { const loadComments = async () => {
loadingComments.value = true loadingComments.value = true
const [openResult, resolvedResult] = await Promise.all([ const [openResult, resolvedResult] = await Promise.all([
@@ -182,10 +238,12 @@ const handleSubmit = async () => {
props.entityId, props.entityId,
content, content,
props.entityName, props.entityName,
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
) )
submitting.value = false submitting.value = false
if (result.success) { if (result.success) {
newContent.value = '' newContent.value = ''
selectedFiles.value = []
await loadComments() await loadComments()
} }
} }

View File

@@ -5,11 +5,13 @@
<ComponentItem <ComponentItem
:component="component" :component="component"
:is-edit-mode="isEditMode" :is-edit-mode="isEditMode"
:show-delete="showDelete"
:collapse-all="collapseAll" :collapse-all="collapseAll"
:toggle-token="toggleToken" :toggle-token="toggleToken"
@update="$emit('update', $event)" @update="$emit('update', $event)"
@edit-piece="$emit('edit-piece', $event)" @edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)" @custom-field-update="$emit('custom-field-update', $event)"
@delete="$emit('delete')"
/> />
</div> </div>
</div> </div>
@@ -27,6 +29,10 @@ defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
showDelete: {
type: Boolean,
default: false
},
collapseAll: { collapseAll: {
type: Boolean, type: Boolean,
default: true default: true
@@ -37,5 +43,5 @@ defineProps({
} }
}) })
defineEmits(['update', 'edit-piece', 'custom-field-update']) defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
</script> </script>

View File

@@ -1,304 +1,210 @@
<template> <template>
<div class="space-y-4"> <div>
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="componentDocuments" :documents="componentDocuments"
@close="closePreview" @close="closePreview"
/> />
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<!-- Component Header --> <!-- Component Header -->
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
<div class="flex items-start gap-3 w-full"> <IconLucideChevronRight
<button class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
type="button" :class="{ 'rotate-90': !isCollapsed }"
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform" aria-hidden="true"
:class="{ 'rotate-90': !isCollapsed }" />
:aria-expanded="!isCollapsed" <div class="flex-1 min-w-0">
:title="isCollapsed ? 'Déplier les détails du composant' : 'Replier les détails du composant'" <div class="flex items-center gap-2 flex-wrap">
@click="toggleCollapse" <h3 class="text-sm font-semibold text-base-content truncate">
>
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} le composant</span>
</button>
<div class="flex-1">
<h3 class="text-lg font-semibold">
{{ component.name }} {{ component.name }}
</h3> </h3>
<div class="flex flex-wrap gap-2 mt-2"> <span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span> <span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span>
<template v-if="componentConstructeursDisplay.length"> </div>
<span <div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
v-for="constructeur in componentConstructeursDisplay" <span
:key="constructeur.id" v-for="constructeur in componentConstructeursDisplay"
class="badge badge-outline badge-sm" :key="constructeur.id"
> class="text-xs text-base-content/50"
{{ constructeur.name }} >
</span> {{ constructeur.name }}
</template> <span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
<span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}</span> </span>
<span <span v-if="displayProductName" class="badge badge-info badge-xs">
v-if="displayProductName" {{ displayProductName }}
class="badge badge-info badge-sm" </span>
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div>
</div> </div>
</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> </div>
<div v-show="!isCollapsed" class="space-y-4"> <!-- Expanded content -->
<!-- Component Info Display - Editable or Read-only --> <div v-show="!isCollapsed" class="mt-3 space-y-4 pl-7">
<div class="p-4 bg-base-100 border border-gray-200 rounded-lg"> <!-- Info fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-medium">Nom</span></label> <label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
<input <input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
v-if="isEditMode" </div>
v-model="component.name" <div class="form-control">
type="text" <label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
class="input input-bordered input-sm" <input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
@blur="updateComponent" </div>
> <div class="form-control">
<div v-else class="input input-bordered input-sm bg-base-200"> <label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
{{ component.name }} <input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
</div> </div>
</div> <div class="form-control">
<div class="form-control"> <label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
<label class="label"><span class="label-text font-medium">Référence</span></label> <ConstructeurSelect
<input class="w-full"
v-if="isEditMode" :model-value="componentConstructeurIds"
v-model="component.reference" :initial-options="componentConstructeursDisplay"
type="text" @update:model-value="handleConstructeurChange"
class="input input-bordered input-sm" />
@blur="updateComponent"
>
<div v-else class="input input-bordered input-sm bg-base-200">
{{ component.reference || 'Non définie' }}
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Prix</span></label>
<input
v-if="isEditMode"
v-model="component.prix"
type="number"
step="0.01"
class="input input-bordered input-sm"
@blur="updateComponent"
>
<div v-else class="input input-bordered input-sm bg-base-200">
{{ component.prix ? `${component.prix}` : 'Non défini' }}
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Fournisseur</span></label>
<ConstructeurSelect
v-if="isEditMode"
class="w-full"
:model-value="componentConstructeurIds"
:initial-options="componentConstructeursDisplay"
@update:model-value="handleConstructeurChange"
/>
<div v-else class="input input-bordered input-sm bg-base-200">
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
<div
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="flex flex-col"
>
<span class="font-medium">{{ constructeur.name }}</span>
<span
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-gray-500"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div>
</div>
<span v-else class="font-medium">Non défini</span>
</div>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Produit catalogue</span>
</label>
<div class="input input-bordered input-sm bg-base-200 min-h-[2.75rem] flex flex-col justify-center space-y-1">
<template v-if="displayProduct">
<span class="font-semibold text-base-content">
{{ displayProductName || 'Produit catalogue' }}
</span>
<span
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/70"
>
{{ info.label }} : {{ info.value }}
</span>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}/edit`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</template>
<span v-else class="font-medium">Non défini</span>
</div>
<div
v-if="productDocuments.length"
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
>
<h4 class="font-medium text-base-content">
Documents du produit
</h4>
<div
v-for="document in productDocuments"
: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-12 w-10"
>
<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 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="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- Custom Fields Display - Editable or Read-only --> <!-- Read-only info -->
<div v-if="displayedCustomFields.length" class="mt-4 pt-4 border-t border-gray-200"> <div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
<h4 class="font-semibold text-sm text-gray-700 mb-3"> <div>
Champs personnalisés <p class="text-xs text-base-content/40 mb-0.5">Nom</p>
</h4> <p class="text-base-content">{{ component.name }}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> </div>
<div <div>
v-for="(field, index) in displayedCustomFields" <p class="text-xs text-base-content/40 mb-0.5">Référence</p>
:key="resolveFieldKey(field, index)" <p class="text-base-content">{{ component.reference || '—' }}</p>
class="form-control" </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"
> >
<label class="label"> Voir le produit
<span class="label-text text-sm">{{ resolveFieldName(field) }}</span> </NuxtLink>
<span v-if="resolveFieldRequired(field)" class="label-text-alt text-error">*</span> </div>
</label> <!-- Product documents -->
<template v-if="isEditMode && !resolveFieldReadOnly(field)"> <div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
<input <p class="text-xs font-medium text-base-content/50">Documents du produit</p>
v-if="resolveFieldType(field) === 'text'" <div
v-model="field.value" v-for="document in productDocuments"
type="text" :key="document.id || document.path || document.name"
class="input input-bordered input-sm" class="flex items-center justify-between gap-3 text-xs"
:required="resolveFieldRequired(field)" >
@blur="updateComponentCustomField(field)" <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">
<input <img
v-else-if="resolveFieldType(field) === 'number'" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
v-model="field.value" :src="document.fileUrl || document.path"
type="number" class="h-full w-full object-cover"
class="input input-bordered input-sm" :alt="`Aperçu de ${document.name}`"
:required="resolveFieldRequired(field)"
@blur="updateComponentCustomField(field)"
>
<select
v-else-if="resolveFieldType(field) === 'select'"
v-model="field.value"
class="select select-bordered select-sm"
:required="resolveFieldRequired(field)"
@change="updateComponentCustomField(field)"
>
<option value="">
Sélectionner...
</option>
<option v-for="option in resolveFieldOptions(field)" :key="option" :value="option">
{{ option }}
</option>
</select>
<div v-else-if="resolveFieldType(field) === 'boolean'" class="flex items-center gap-2">
<input
v-model="field.value"
type="checkbox"
class="checkbox checkbox-sm"
true-value="true"
false-value="false"
@change="updateComponentCustomField(field)"
> >
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span> <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> </div>
<input <span class="truncate text-base-content">{{ document.name }}</span>
v-else-if="resolveFieldType(field) === 'date'" </div>
v-model="field.value" <div class="flex items-center gap-1 shrink-0">
type="date" <button
class="input input-bordered input-sm" type="button"
:required="resolveFieldRequired(field)" class="btn btn-ghost btn-xs"
@blur="updateComponentCustomField(field)" :disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
> >
</template> Consulter
<template v-else> </button>
<div class="input input-bordered input-sm bg-base-200"> <button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
{{ formatFieldDisplayValue(field) }} Télécharger
</div> </button>
</template> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3"> <!-- 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"> <div class="flex items-center justify-between">
<h4 class="font-semibold text-sm text-gray-700"> <p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
Documents <span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
</h4> {{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
</span> </span>
</div> </div>
<p v-if="loadingDocuments" class="text-xs text-gray-500"> <p v-if="loadingDocuments" class="text-xs text-base-content/50">
Chargement des documents... Chargement...
</p> </p>
<DocumentUpload <DocumentUpload
@@ -309,84 +215,26 @@
@files-added="handleFilesAdded" @files-added="handleFilesAdded"
/> />
<div v-if="componentDocuments.length" class="space-y-2"> <DocumentListInline
<div :documents="componentDocuments"
v-for="document in componentDocuments" :can-delete="isEditMode"
:key="document.id" :can-edit="isEditMode"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2" :delete-disabled="uploadingDocuments"
> empty-text="Aucun document lié à ce composant."
<div class="flex items-center gap-3 text-sm"> @preview="openPreview"
<div @edit="openEditModal"
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center" @delete="removeDocument"
: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">
{{ document.name }}
</div>
<div class="text-xs text-gray-500">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<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="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else-if="!loadingDocuments" class="text-xs text-gray-500">
Aucun document lié à ce composant.
</p>
</div> </div>
<!-- Component Pieces --> <!-- Component Pieces (real MachinePieceLinks) -->
<div v-if="component.pieces && component.pieces.length > 0" class="space-y-2"> <div v-if="linkedPieces.length > 0" class="space-y-2">
<h4 class="font-semibold text-gray-700"> <p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Pièces du composant Pièces du composant
</h4> </p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="space-y-2">
<PieceItem <PieceItem
v-for="piece in component.pieces" v-for="piece in linkedPieces"
:key="piece.id" :key="piece.id"
:piece="piece" :piece="piece"
:is-edit-mode="isEditMode" :is-edit-mode="isEditMode"
@@ -397,12 +245,27 @@
</div> </div>
</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 --> <!-- Sub Components -->
<div v-if="childComponents.length > 0" class="space-y-3"> <div v-if="childComponents.length > 0" class="space-y-2">
<h4 class="font-semibold text-gray-700"> <p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
Sous-composants Sous-composants
</h4> </p>
<div class="space-y-3 pl-4 border-l-2 border-gray-200"> <div class="space-y-2 pl-4 border-l-2 border-base-200">
<ComponentItem <ComponentItem
v-for="subComponent in childComponents" v-for="subComponent in childComponents"
:key="subComponent.id" :key="subComponent.id"
@@ -433,24 +296,15 @@ import {
formatConstructeurContact as formatConstructeurContactSummary, formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs, resolveConstructeurs,
uniqueConstructeurIds, uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils' } from '~/shared/constructeurUtils'
import { import {
formatSize, formatSize,
shouldInlinePdf, shouldInlinePdf,
documentPreviewSrc, documentPreviewSrc,
documentThumbnailClass,
documentIcon, documentIcon,
downloadDocument, downloadDocument,
} from '~/shared/utils/documentDisplayUtils' } from '~/shared/utils/documentDisplayUtils'
import {
resolveFieldKey,
resolveFieldName,
resolveFieldType,
resolveFieldOptions,
resolveFieldRequired,
resolveFieldReadOnly,
formatFieldDisplayValue,
} from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments' import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay' import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields' import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
@@ -458,11 +312,12 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({ const props = defineProps({
component: { type: Object, required: true }, component: { type: Object, required: true },
isEditMode: { type: Boolean, default: false }, isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true }, collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 }, toggleToken: { type: Number, default: 0 },
}) })
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update']) const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
// --- Shared composables --- // --- Shared composables ---
const { const {
@@ -477,6 +332,7 @@ const {
ensureDocumentsLoaded, ensureDocumentsLoaded,
handleFilesAdded, handleFilesAdded,
removeDocument, removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' }) } = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
const { const {
@@ -491,6 +347,21 @@ const {
updateCustomField: updateComponentCustomField, updateCustomField: updateComponentCustomField,
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' }) } = 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 --- // --- Collapse state ---
const isCollapsed = ref(true) const isCollapsed = ref(true)
@@ -514,26 +385,47 @@ const childComponents = computed(() => {
return Array.isArray(list) ? list : [] 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 --- // --- Constructeurs ---
const { constructeurs } = useConstructeurs() const { constructeurs } = useConstructeurs()
const componentConstructeurIds = computed(() => const componentConstructeurLinks = computed(() =>
uniqueConstructeurIds( parseConstructeurLinksFromApi(
props.component,
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [], Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
props.component.constructeur ? [props.component.constructeur] : [],
), ),
) )
const componentConstructeursDisplay = computed(() => const supplierReferenceMap = computed(() => {
resolveConstructeurs( const map = new Map()
componentConstructeurIds.value, componentConstructeurLinks.value.forEach(l => {
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [], if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
props.component.constructeur ? [props.component.constructeur] : [], })
constructeurs.value, 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) => const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur) formatConstructeurContactSummary(constructeur)

View File

@@ -10,7 +10,6 @@
:locked-type-label="displayedRootTypeLabel" :locked-type-label="displayedRootTypeLabel"
:allow-subcomponents="allowSubcomponents" :allow-subcomponents="allowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth" :max-subcomponent-depth="maxSubcomponentDepth"
:restricted-mode="restrictedMode"
is-root is-root
/> />
</div> </div>
@@ -56,10 +55,6 @@ const props = defineProps({
type: Number, type: Number,
default: Infinity, default: Infinity,
}, },
restrictedMode: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])

View File

@@ -23,6 +23,7 @@
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'" :empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
:option-label="componentOptionLabel" :option-label="componentOptionLabel"
:option-description="componentOptionDescription" :option-description="componentOptionDescription"
server-search
@search="fetchComponentOptions" @search="fetchComponentOptions"
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
/> />
@@ -62,6 +63,7 @@
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'" :empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
:option-label="pieceOptionLabel" :option-label="pieceOptionLabel"
:option-description="pieceOptionDescription" :option-description="pieceOptionDescription"
server-search
@search="(term) => fetchPieceOptions(pieceAssignment, term)" @search="(term) => fetchPieceOptions(pieceAssignment, term)"
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
/> />
@@ -101,6 +103,7 @@
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'" :empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
:option-label="productOptionLabel" :option-label="productOptionLabel"
:option-description="productOptionDescription" :option-description="productOptionDescription"
server-search
@search="(term) => fetchProductOptions(productAssignment, term)" @search="(term) => fetchProductOptions(productAssignment, term)"
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }" @update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
/> />
@@ -134,76 +137,24 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue'; import SearchSelect from '~/components/common/SearchSelect.vue';
import { useApi } from '~/composables/useApi'; import { useStructureAssignmentFetch } from '~/composables/useStructureAssignmentFetch';
import { extractCollection } from '~/shared/utils/apiHelpers';
import type { import type {
ComponentModelPiece, ComponentOption,
ComponentModelProduct, PieceOption,
ComponentModelStructureNode, ProductOption,
} from '~/shared/types/inventory'; } from '~/composables/useStructureAssignmentFetch';
interface ComponentOption { export type {
id: string; StructureAssignmentNode,
name?: string | null; StructurePieceAssignment,
reference?: string | null; StructureProductAssignment,
typeComposantId?: string | null; } from '~/composables/useStructureAssignmentFetch';
typeComposant?: {
id: string;
name?: string | null;
code?: string | null;
} | null;
}
interface PieceOption {
id: string;
name?: string | null;
reference?: string | null;
typePieceId?: string | null;
typePiece?: {
id: string;
name?: string | null;
code?: string | null;
} | null;
}
interface ProductOption {
id: string;
name?: string | null;
reference?: string | null;
typeProductId?: string | null;
typeProduct?: {
id: string;
name?: string | null;
code?: string | null;
} | null;
}
export interface StructurePieceAssignment {
path: string;
definition: ComponentModelPiece;
selectedPieceId: string;
}
export interface StructureProductAssignment {
path: string;
definition: ComponentModelProduct;
selectedProductId: string;
}
export interface StructureAssignmentNode {
path: string;
definition: ComponentModelStructureNode;
selectedComponentId: string;
pieces: StructurePieceAssignment[];
products: StructureProductAssignment[];
subcomponents: StructureAssignmentNode[];
}
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
assignment: StructureAssignmentNode; assignment: import('~/composables/useStructureAssignmentFetch').StructureAssignmentNode;
pieces: PieceOption[] | null; pieces: PieceOption[] | null;
products: ProductOption[] | null; products: ProductOption[] | null;
components: ComponentOption[] | null; components: ComponentOption[] | null;
@@ -236,331 +187,46 @@ const wrapperClass = computed(() =>
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4', depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
); );
const { get } = useApi(); const {
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({}); pieceLoadingByPath,
const productOptionsByPath = ref<Record<string, ProductOption[]>>({}); productLoadingByPath,
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({}); componentLoadingByPath,
const pieceLoadingByPath = ref<Record<string, boolean>>({}); componentOptions,
const productLoadingByPath = ref<Record<string, boolean>>({}); componentOptionLabel,
const componentLoadingByPath = ref<Record<string, boolean>>({}); componentOptionDescription,
fetchComponentOptions,
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => { getPieceOptions,
target[key] = value; pieceOptionLabel,
}; pieceOptionDescription,
fetchPieceOptions,
const componentOptions = computed(() => { describePieceRequirement,
if (isRoot.value) { getProductOptions,
return []; productOptionLabel,
} productOptionDescription,
const cached = componentOptionsByPath.value[props.assignment.path]; fetchProductOptions,
if (cached) { describeProductRequirement,
return cached; } = useStructureAssignmentFetch({
} assignment: props.assignment,
const definition = props.assignment.definition || {}; pieces: props.pieces,
const requiredTypeId = products: props.products,
definition.typeComposantId || definition.modelId || null; components: props.components,
const requiredFamilyCode = definition.familyCode || null; isRoot: () => isRoot.value,
pieceTypeLabelMap: props.pieceTypeLabelMap ?? {},
return (props.components || []).filter((component) => { productTypeLabelMap: props.productTypeLabelMap ?? {},
if (!component || typeof component !== 'object') { componentTypeLabelMap: props.componentTypeLabelMap ?? {},
return false;
}
if (requiredTypeId) {
return component.typeComposantId === requiredTypeId;
}
if (requiredFamilyCode) {
return (
component.typeComposant?.code === requiredFamilyCode ||
component.typeComposantId === requiredFamilyCode
);
}
return true;
});
}); });
const componentOptionLabel = (component?: ComponentOption | null) => { const normalizeSelectionValue = (value: unknown) => {
if (!component) { if (value === null || value === undefined || value === '') {
return 'Composant sans nom';
}
return component.name || 'Composant sans nom';
};
const componentOptionDescription = (component?: ComponentOption | null) => {
if (!component) {
return ''; return '';
} }
const parts: string[] = []; if (typeof value === 'string') {
const typeLabel = return value;
component.typeComposant?.name || component.typeComposant?.code || null;
if (typeLabel) {
parts.push(typeLabel);
} }
if (component.reference) { if (typeof value === 'number') {
parts.push(`Ref. ${component.reference}`); return String(value);
} }
return parts.join(' • '); return '';
};
const typeIri = (id: string) => `/api/model_types/${id}`;
const primedPiecePaths = new Set<string>();
const primedProductPaths = new Set<string>();
const primedComponentPaths = new Set<string>();
const fetchComponentOptions = async (term = '') => {
if (isRoot.value) {
return;
}
const key = props.assignment.path;
if (componentLoadingByPath.value[key]) {
return;
}
const definition = props.assignment.definition || {};
const requiredTypeId =
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typeComposant', typeIri(requiredTypeId));
}
setLoading(componentLoadingByPath.value, key, true);
try {
const result = await get(`/composants?${params.toString()}`);
if (result.success) {
componentOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(componentLoadingByPath.value, key, false);
}
};
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
const key = assignment.path;
if (pieceLoadingByPath.value[key]) {
return;
}
const definition = assignment.definition || {};
const requiredTypeId =
definition.typePieceId || definition.typePiece?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typePiece', typeIri(requiredTypeId));
}
setLoading(pieceLoadingByPath.value, key, true);
try {
const result = await get(`/pieces?${params.toString()}`);
if (result.success) {
pieceOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(pieceLoadingByPath.value, key, false);
}
};
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
const key = assignment.path;
if (productLoadingByPath.value[key]) {
return;
}
const definition = assignment.definition || {};
const requiredTypeId =
definition.typeProductId || definition.typeProduct?.id || null;
const params = new URLSearchParams();
params.set('itemsPerPage', '50');
if (term.trim()) {
params.set('name', term.trim());
}
if (requiredTypeId) {
params.set('typeProduct', typeIri(requiredTypeId));
}
setLoading(productLoadingByPath.value, key, true);
try {
const result = await get(`/products?${params.toString()}`);
if (result.success) {
productOptionsByPath.value[key] = extractCollection(result.data);
}
} finally {
setLoading(productLoadingByPath.value, key, false);
}
};
watch(
componentOptions,
(options) => {
if (isRoot.value) {
return;
}
const hasMatch = options.some(
(component) => component.id === props.assignment.selectedComponentId,
);
if (!hasMatch) {
props.assignment.selectedComponentId = '';
}
},
{ immediate: true },
);
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
const definition = assignment.definition;
const parts: string[] = [];
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed && !parts.includes(trimmed)) {
parts.push(trimmed);
}
};
const options = getPieceOptions(assignment);
const fallbackPiece = options[0] || null;
const fallbackType = fallbackPiece?.typePiece || null;
addPart(definition.role);
const explicitLabel =
definition.typePieceLabel ||
definition.typePiece?.name ||
(definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
fallbackType?.name;
addPart(explicitLabel);
const family =
definition.familyCode ||
definition.typePiece?.code ||
fallbackType?.code ||
null;
if (family) {
addPart(`Famille ${family}`);
}
if (parts.length === 0) {
addPart(fallbackType?.name);
if (fallbackType?.code) {
addPart(`Famille ${fallbackType.code}`);
}
}
if (parts.length === 0 && definition.typePieceId) {
addPart(`#${definition.typePieceId}`);
}
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
};
const getProductOptions = (assignment: StructureProductAssignment) => {
const cached = productOptionsByPath.value[assignment.path];
if (cached) {
return cached;
}
const definition = assignment.definition;
const requiredTypeId =
definition.typeProductId ||
definition.typeProduct?.id ||
definition.familyCode ||
null;
return (props.products || []).filter((product) => {
if (!product || typeof product !== 'object') {
return false;
}
if (!requiredTypeId) {
return true;
}
if (definition.typeProductId || definition.typeProduct?.id) {
return (
product.typeProductId === requiredTypeId ||
product.typeProduct?.id === requiredTypeId
);
}
if (definition.familyCode) {
return (
product.typeProduct?.code === requiredTypeId ||
product.typeProductId === requiredTypeId
);
}
return false;
});
};
const productOptionLabel = (product?: ProductOption | null) => {
if (!product) {
return 'Produit';
}
return product.name || product.reference || 'Produit';
};
const productOptionDescription = (product?: ProductOption | null) => {
if (!product) {
return '';
}
const parts: string[] = [];
const typeLabel =
product.typeProduct?.name || product.typeProduct?.code || null;
if (typeLabel) {
parts.push(typeLabel);
}
if (product.reference) {
parts.push(`Ref. ${product.reference}`);
}
return parts.join(' • ');
};
const describeProductRequirement = (assignment: StructureProductAssignment) => {
const definition = assignment.definition;
const parts: string[] = [];
const addPart = (value?: string | null) => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed && !parts.includes(trimmed)) {
parts.push(trimmed);
}
};
const options = getProductOptions(assignment);
const fallbackProduct = options[0] || null;
const fallbackType = fallbackProduct?.typeProduct || null;
addPart(definition.role);
const explicitLabel =
definition.typeProductLabel ||
definition.typeProduct?.name ||
(definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
fallbackType?.name;
addPart(explicitLabel);
const family =
definition.familyCode ||
definition.typeProduct?.code ||
fallbackType?.code ||
null;
if (family) {
addPart(`Famille ${family}`);
}
if (parts.length === 0) {
addPart(fallbackType?.name);
if (fallbackType?.code) {
addPart(`Famille ${fallbackType.code}`);
}
}
if (parts.length === 0 && definition.typeProductId) {
addPart(`#${definition.typeProductId}`);
}
return parts.length ? parts.join(' • ') : 'Produit du squelette';
}; };
const requirementLabel = computed(() => { const requirementLabel = computed(() => {
@@ -584,139 +250,13 @@ const requirementLabel = computed(() => {
const requirementDescription = computed(() => { const requirementDescription = computed(() => {
const definition = props.assignment.definition || {}; const definition = props.assignment.definition || {};
const family = const family =
definition.typeComposantLabel || definition.typeComposantLabel
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) || || (definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null)
definition.typeComposant?.name || || definition.typeComposant?.name
definition.familyCode; || definition.familyCode;
if (family) { if (family) {
return `Doit appartenir à la famille "${family}".`; return `Doit appartenir à la famille "${family}".`;
} }
return 'Sélectionnez un composant enfant conforme à cette position.'; return 'Sélectionnez un composant enfant conforme à cette position.';
}); });
const getPieceOptions = (assignment: StructurePieceAssignment) => {
const cached = pieceOptionsByPath.value[assignment.path];
if (cached) {
return cached;
}
const definition = assignment.definition;
const requiredTypeId =
definition.typePieceId ||
definition.typePiece?.id ||
definition.familyCode ||
null;
return (props.pieces || []).filter((piece) => {
if (!piece || typeof piece !== 'object') {
return false;
}
if (!requiredTypeId) {
return true;
}
if (definition.typePieceId || definition.typePiece?.id) {
return (
piece.typePieceId === requiredTypeId ||
piece.typePiece?.id === requiredTypeId
);
}
if (definition.familyCode) {
return (
piece.typePiece?.code === requiredTypeId ||
piece.typePieceId === requiredTypeId
);
}
return false;
});
};
const pieceOptionLabel = (piece?: PieceOption | null) => {
if (!piece) {
return 'Pièce';
}
return piece.name || 'Pièce';
};
const pieceOptionDescription = (piece?: PieceOption | null) => {
if (!piece) {
return '';
}
const parts: string[] = [];
const typeLabel =
piece.typePiece?.name || piece.typePiece?.code || null;
if (typeLabel) {
parts.push(typeLabel);
}
if (piece.reference) {
parts.push(`Ref. ${piece.reference}`);
}
return parts.join(' • ');
};
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 '';
};
watch(
() => [props.pieces, props.assignment.pieces],
() => {
for (const pieceAssignment of props.assignment.pieces) {
const options = getPieceOptions(pieceAssignment);
if (
pieceAssignment.selectedPieceId &&
!options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
) {
pieceAssignment.selectedPieceId = '';
}
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
primedPiecePaths.add(pieceAssignment.path);
fetchPieceOptions(pieceAssignment).catch(() => {});
}
}
},
{ deep: true, immediate: true },
);
watch(
() => [props.products, props.assignment.products],
() => {
for (const productAssignment of props.assignment.products) {
const options = getProductOptions(productAssignment);
if (
productAssignment.selectedProductId &&
!options.some((product) => product.id === productAssignment.selectedProductId)
) {
productAssignment.selectedProductId = '';
}
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
primedProductPaths.add(productAssignment.path);
fetchProductOptions(productAssignment).catch(() => {});
}
}
},
{ deep: true, immediate: true },
);
watch(
() => props.assignment.definition,
() => {
if (isRoot.value) {
return;
}
const key = props.assignment.path;
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
primedComponentPaths.add(key);
fetchComponentOptions().catch(() => {});
}
},
{ immediate: true },
);
</script> </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

@@ -1,6 +1,6 @@
<template> <template>
<div v-if="customFields && customFields.length > 0" class="space-y-4"> <div v-if="customFields && customFields.length > 0" class="space-y-4">
<h4 class="font-semibold text-gray-700 mb-3"> <h4 class="font-semibold text-base-content/80 mb-3">
Champs personnalisés Champs personnalisés
</h4> </h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">

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

@@ -10,11 +10,11 @@
<div class="min-w-0"> <div class="min-w-0">
<h3 class="font-bold text-xl truncate"> <h3 class="font-bold text-xl truncate">
Prévisualisation Prévisualisation
<span v-if="navTotal > 1" class="text-base font-normal text-gray-500"> <span v-if="navTotal > 1" class="text-base font-normal text-base-content/50">
{{ activeIndex + 1 }} / {{ navTotal }} {{ activeIndex + 1 }} / {{ navTotal }}
</span> </span>
</h3> </h3>
<p class="text-sm text-gray-500 truncate"> <p class="text-sm text-base-content/50 truncate">
{{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> &bull; {{ documentDescription }}</span> {{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> &bull; {{ documentDescription }}</span>
</p> </p>
</div> </div>
@@ -68,7 +68,7 @@
<template v-else-if="previewType === 'text'"> <template v-else-if="previewType === 'text'">
<div class="w-full h-full overflow-auto"> <div class="w-full h-full overflow-auto">
<div v-if="textLoading" class="flex items-center justify-center py-10 text-sm text-gray-500"> <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" /> <span class="loading loading-spinner loading-md mr-2" />
Chargement du document... Chargement du document...
</div> </div>
@@ -82,7 +82,7 @@
</template> </template>
<template v-else> <template v-else>
<div class="text-sm text-gray-500 text-center px-6"> <div class="text-sm text-base-content/50 text-center px-6">
Prévisualisation non disponible pour ce type de document. Prévisualisation non disponible pour ce type de document.
</div> </div>
</template> </template>

View File

@@ -13,7 +13,7 @@
<h3 class="font-semibold"> <h3 class="font-semibold">
{{ title }} {{ title }}
</h3> </h3>
<p class="text-sm text-gray-500"> <p class="text-sm text-base-content/50">
{{ subtitle }} {{ subtitle }}
</p> </p>
</div> </div>
@@ -22,7 +22,7 @@
<button type="button" class="btn btn-primary btn-sm" @click="triggerFileDialog"> <button type="button" class="btn btn-primary btn-sm" @click="triggerFileDialog">
Sélectionner des fichiers Sélectionner des fichiers
</button> </button>
<span class="text-xs text-gray-500">ou glisser-déposer ici</span> <span class="text-xs text-base-content/50">ou glisser-déposer ici</span>
</div> </div>
<input <input
@@ -34,6 +34,21 @@
@change="onFileChange" @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"> <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"> <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="flex items-center gap-3">
@@ -54,7 +69,7 @@
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="font-medium">{{ file.name }}</span> <span class="font-medium">{{ file.name }}</span>
<span class="text-xs text-gray-500">{{ formatSize(file.size) }} {{ file.type || 'Type inconnu' }}</span> <span class="text-xs text-base-content/50">{{ formatSize(file.size) }} {{ file.type || 'Type inconnu' }}</span>
</div> </div>
</div> </div>
<button type="button" class="btn btn-ghost btn-xs" @click="removeFile(file)"> <button type="button" class="btn btn-ghost btn-xs" @click="removeFile(file)">
@@ -69,6 +84,7 @@
<script setup> <script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue' import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
import { getFileIcon } from '~/utils/fileIcons' import { getFileIcon } from '~/utils/fileIcons'
import IconLucideCloudUpload from '~icons/lucide/cloud-upload' import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
@@ -96,10 +112,14 @@ const props = defineProps({
maxFileSizeMb: { maxFileSizeMb: {
type: Number, type: Number,
default: 200 default: 200
},
documentType: {
type: String,
default: 'documentation'
} }
}) })
const emit = defineEmits(['update:modelValue', 'files-added']) const emit = defineEmits(['update:modelValue', 'files-added', 'update:documentType'])
const dragActive = ref(false) const dragActive = ref(false)
const fileInput = ref(null) const fileInput = ref(null)
@@ -130,7 +150,7 @@ const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
}) })
} }
const selectedFiles = computed(() => internalFiles.value) const selectedFiles = internalFiles
watch( watch(
() => props.modelValue, () => props.modelValue,

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-600"> <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.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.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.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-gray-500"> <span v-if="!stats.customFields && !stats.pieces && !stats.subcomponents" class="text-xs text-base-content/50">
Structure vide Structure vide
</span> </span>
</div> </div>

View File

@@ -1,11 +1,11 @@
<template> <template>
<section :class="sectionClasses"> <section :class="sectionClasses">
<div :class="contentClasses"> <div :class="contentClasses">
<div :class="['space-y-4', maxWidthClass]"> <div :class="['space-y-3', maxWidthClass]">
<component :is="headingTag" v-if="title" class="text-4xl font-bold"> <component :is="headingTag" v-if="title" class="text-4xl font-bold tracking-tight">
{{ title }} {{ title }}
</component> </component>
<p v-if="subtitle" class="text-sm opacity-90"> <p v-if="subtitle" class="text-sm opacity-80 leading-relaxed">
{{ subtitle }} {{ subtitle }}
</p> </p>
<slot /> <slot />
@@ -58,9 +58,9 @@ const props = defineProps({
}) })
const sectionClasses = computed(() => { const sectionClasses = computed(() => {
const classes = ['hero', 'bg-gradient-to-r', props.gradientFrom, props.gradientTo, props.minHeight] const classes = ['hero', 'bg-gradient-to-br', props.gradientFrom, props.gradientTo, props.minHeight]
if (props.rounded) { if (props.rounded) {
classes.push('rounded-lg') classes.push('rounded-xl', 'overflow-hidden')
} }
return classes return classes
}) })

View File

@@ -6,10 +6,16 @@
:documents="pieceDocuments" :documents="pieceDocuments"
@close="closePreview" @close="closePreview"
/> />
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<!-- Piece Header (collapsible, same pattern as ComponentItem) --> <!-- 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 justify-between p-4 bg-base-200 rounded-lg">
<div class="flex items-start gap-3 w-full"> <div class="flex items-start gap-3 flex-1 min-w-0">
<button <button
type="button" type="button"
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform" class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
@@ -21,15 +27,22 @@
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" /> <IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span> <span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
</button> </button>
<div class="flex-1"> <div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold"> <h3 class="text-lg font-semibold">
{{ pieceData.name }} {{ pieceData.name }}
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
</h3> </h3>
<div class="flex flex-wrap gap-2 mt-2"> <div class="flex flex-wrap gap-2 mt-2">
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm"> <span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
Rattachée à {{ piece.parentComponentName }} Rattachée à {{ piece.parentComponentName }}
</span> </span>
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</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"> <template v-if="pieceConstructeursDisplay.length">
<span <span
v-for="constructeur in pieceConstructeursDisplay" v-for="constructeur in pieceConstructeursDisplay"
@@ -37,6 +50,9 @@
class="badge badge-outline badge-sm" class="badge badge-outline badge-sm"
> >
{{ constructeur.name }} {{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
({{ supplierReferenceMap.get(constructeur.id) }})
</span>
</span> </span>
</template> </template>
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span> <span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span>
@@ -49,11 +65,37 @@
</div> </div>
</div> </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>
<div v-show="!isCollapsed" class="space-y-4"> <div v-show="!isCollapsed" class="space-y-4">
<div class="p-4 bg-base-100 border border-gray-200 rounded-lg"> <div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<div class="space-y-2 text-sm"> <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> <div>
<span class="font-medium">Référence:</span> <span class="font-medium">Référence:</span>
<input <input
@@ -68,6 +110,10 @@
pieceData.reference || "Non définie" pieceData.reference || "Non définie"
}}</span> }}</span>
</div> </div>
<div v-if="pieceData.referenceAuto">
<span class="font-medium">Référence auto:</span>
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
</div>
<div> <div>
<span class="font-medium">Fournisseur:</span> <span class="font-medium">Fournisseur:</span>
<div v-if="!isEditMode" class="ml-2"> <div v-if="!isEditMode" class="ml-2">
@@ -79,10 +125,13 @@
> >
<span class="font-medium"> <span class="font-medium">
{{ constructeur.name }} {{ 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>
<span <span
v-if="formatConstructeurContact(constructeur)" v-if="formatConstructeurContact(constructeur)"
class="text-xs text-gray-500" class="text-xs text-base-content/50"
> >
{{ formatConstructeurContact(constructeur) }} {{ formatConstructeurContact(constructeur) }}
</span> </span>
@@ -142,7 +191,7 @@
</p> </p>
<NuxtLink <NuxtLink
v-if="selectedProduct.id" v-if="selectedProduct.id"
:to="`/product/${selectedProduct.id}/edit`" :to="`/product/${selectedProduct.id}`"
class="link link-primary text-xs" class="link link-primary text-xs"
> >
Ouvrir la fiche produit Ouvrir la fiche produit
@@ -165,65 +214,10 @@
<span class="font-semibold">{{ info.label }} :</span> <span class="font-semibold">{{ info.label }} :</span>
<span class="ml-1">{{ info.value }}</span> <span class="ml-1">{{ info.value }}</span>
</p> </p>
<div <ProductDocumentsInline
v-if="productDocuments.length" :documents="productDocuments"
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs" @preview="openPreview"
> />
<h5 class="font-medium text-base-content">Documents du produit</h5>
<div
v-for="document in productDocuments"
: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="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div> </div>
<span v-else class="font-medium"> <span v-else class="font-medium">
Non défini Non défini
@@ -234,147 +228,16 @@
</div> </div>
<!-- Champs personnalisés de la pièce --> <!-- Champs personnalisés de la pièce -->
<div <CustomFieldDisplay
v-if="displayedCustomFields.length" :fields="displayedCustomFields"
class="mt-4 pt-4 border-t border-gray-200" :is-edit-mode="isEditMode"
> @field-input="handleCustomFieldInput"
<h5 class="text-sm font-medium text-gray-700 mb-3"> @field-blur="handleCustomFieldBlur"
Champs personnalisés />
</h5>
<div class="space-y-3">
<div
v-for="(field, index) in displayedCustomFields"
: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 --> <div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<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="
setCustomFieldValue(
resolveFieldId(field),
$event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), 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="
setCustomFieldValue(
resolveFieldId(field),
$event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), 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="
(event) =>
setCustomFieldValue(
resolveFieldId(field),
event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), 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
:value="field.value ?? ''"
type="checkbox"
class="checkbox checkbox-sm"
:checked="String(field.value).toLowerCase() === 'true'"
@change="
setCustomFieldValue(
resolveFieldId(field),
$event.target.checked ? 'true' : 'false',
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
/>
<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="
setCustomFieldValue(
resolveFieldId(field),
$event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), 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>
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h5 class="text-sm font-medium text-gray-700">Documents</h5> <h5 class="text-sm font-medium text-base-content/80">Documents</h5>
<span <span
v-if="isEditMode && selectedFiles.length" v-if="isEditMode && selectedFiles.length"
class="badge badge-outline" class="badge badge-outline"
@@ -386,7 +249,7 @@
</span> </span>
</div> </div>
<p v-if="loadingDocuments" class="text-xs text-gray-500"> <p v-if="loadingDocuments" class="text-xs text-base-content/50">
Chargement des documents... Chargement des documents...
</p> </p>
@@ -398,83 +261,16 @@
@files-added="handleFilesAdded" @files-added="handleFilesAdded"
/> />
<div v-if="pieceDocuments.length" class="space-y-2"> <DocumentListInline
<div :documents="pieceDocuments"
v-for="document in pieceDocuments" :can-delete="isEditMode"
:key="document.id" :can-edit="isEditMode"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2" :delete-disabled="uploadingDocuments"
> empty-text="Aucun document lié à cette pièce."
<div class="flex items-center gap-3 text-sm"> @preview="openPreview"
<div @edit="openEditModal"
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center" @delete="removeDocument"
: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">
{{ document.name }}
</div>
<div class="text-xs text-gray-500">
{{ document.mimeType || "Inconnu" }}
{{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<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="openPreview(document)"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(document)"
>
Télécharger
</button>
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else-if="!loadingDocuments" class="text-xs text-gray-500">
Aucun document lié à cette pièce.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -487,31 +283,17 @@ import ProductSelect from '~/components/ProductSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right' import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { import {
formatConstructeurContact as formatConstructeurContactSummary, formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs, resolveConstructeurs,
uniqueConstructeurIds, uniqueConstructeurIds,
parseConstructeurLinksFromApi,
} from '~/shared/constructeurUtils' } from '~/shared/constructeurUtils'
import { import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
resolveFieldKey,
resolveFieldId, resolveFieldId,
resolveFieldName,
resolveFieldType,
resolveFieldOptions,
resolveFieldRequired,
resolveFieldReadOnly, resolveFieldReadOnly,
formatFieldDisplayValue,
} from '~/shared/utils/entityCustomFieldLogic' } from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments' import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay' import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
@@ -520,18 +302,25 @@ import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({ const props = defineProps({
piece: { type: Object, required: true }, piece: { type: Object, required: true },
isEditMode: { type: Boolean, default: false }, isEditMode: { type: Boolean, default: false },
showDelete: { type: Boolean, default: false },
collapseAll: { type: Boolean, default: true }, collapseAll: { type: Boolean, default: true },
toggleToken: { type: Number, default: 0 }, toggleToken: { type: Number, default: 0 },
}) })
const emit = defineEmits(['update', 'edit', 'custom-field-update']) const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
// --- Local reactive data for editing --- // --- Local reactive data for editing ---
const pieceData = reactive({ const pieceData = reactive({
name: props.piece.name || '', name: props.piece.name || '',
reference: props.piece.reference || '', reference: props.piece.reference || '',
referenceAuto: props.piece.referenceAuto || null,
prix: props.piece.prix || '', prix: props.piece.prix || '',
productId: props.piece.product?.id || props.piece.productId || null, productId: props.piece.product?.id || props.piece.productId || null,
quantity: props.piece.quantity ?? 1,
})
const displayQuantity = computed(() => {
return pieceData.quantity ?? 1
}) })
// --- Products --- // --- Products ---
@@ -561,6 +350,7 @@ const {
refreshDocuments, refreshDocuments,
handleFilesAdded, handleFilesAdded,
removeDocument, removeDocument,
editDocument,
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' }) } = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
const { const {
@@ -575,6 +365,21 @@ const {
updateCustomField, updateCustomField,
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' }) } = 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 --- // --- Collapse state ---
const isCollapsed = ref(true) const isCollapsed = ref(true)
@@ -595,23 +400,36 @@ const toggleCollapse = () => {
// --- Constructeurs --- // --- Constructeurs ---
const { constructeurs } = useConstructeurs() const { constructeurs } = useConstructeurs()
const pieceConstructeurIds = computed(() => const pieceConstructeurLinks = computed(() =>
uniqueConstructeurIds( parseConstructeurLinksFromApi(
props.piece,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [], Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
props.piece.constructeur ? [props.piece.constructeur] : [],
), ),
) )
const pieceConstructeursDisplay = computed(() => const supplierReferenceMap = computed(() => {
resolveConstructeurs( const map = new Map()
pieceConstructeurIds.value, pieceConstructeurLinks.value.forEach(l => {
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [], if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
props.piece.constructeur ? [props.piece.constructeur] : [], })
constructeurs.value, 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) => const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur) formatConstructeurContactSummary(constructeur)
@@ -665,16 +483,16 @@ const handleProductChange = async (value) => {
updatePiece() updatePiece()
} }
// --- Custom field local helpers --- // --- Custom field event handlers ---
const setCustomFieldValue = (fieldValueId, value, field) => { const handleCustomFieldInput = (field, value) => {
if (resolveFieldReadOnly(field)) return if (resolveFieldReadOnly(field)) return
if (field && typeof field === 'object') field.value = value const fieldValueId = resolveFieldId(field)
if (!fieldValueId) return if (!fieldValueId) return
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId) const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
if (fieldValue) fieldValue.value = value if (fieldValue) fieldValue.value = value
} }
const updateCustomFieldValue = async (_fieldValueId, field) => { const handleCustomFieldBlur = async (field) => {
await updateCustomField(field) await updateCustomField(field)
const cfId = field?.customFieldId || field?.customField?.id || null const cfId = field?.customFieldId || field?.customField?.id || null
if (cfId || field?.customFieldValueId) { if (cfId || field?.customFieldValueId) {
@@ -692,13 +510,14 @@ const updatePiece = () => {
let parsedPrice = null let parsedPrice = null
if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) { if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) {
const numeric = Number(prixValue) const numeric = Number(prixValue)
if (!Number.isNaN(numeric)) parsedPrice = numeric if (!Number.isNaN(numeric)) parsedPrice = String(numeric)
} }
const product = selectedProduct.value ? { ...selectedProduct.value } : null const product = selectedProduct.value ? { ...selectedProduct.value } : null
emit('update', { emit('update', {
...props.piece, ...props.piece,
...pieceData, ...pieceData,
prix: parsedPrice, prix: parsedPrice,
quantity: pieceData.quantity ?? 1,
productId: pieceData.productId || null, productId: pieceData.productId || null,
product, product,
constructeurIds: pieceConstructeurIds.value, constructeurIds: pieceConstructeurIds.value,
@@ -738,11 +557,12 @@ watch(
) )
watch( watch(
() => [props.piece.name, props.piece.reference, props.piece.prix], () => [props.piece.name, props.piece.reference, props.piece.prix, props.piece.quantity],
() => { () => {
pieceData.name = props.piece.name || '' pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || '' pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || '' pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
}, },
) )
@@ -750,6 +570,7 @@ onMounted(() => {
pieceData.name = props.piece.name || '' pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || '' pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || '' pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {}) loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId) if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments() if (!props.piece.documents?.length) refreshDocuments()

View File

@@ -1,22 +1,16 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<section class="space-y-3"> <section class="space-y-3">
<header class="flex items-center justify-between"> <header>
<div> <h3 class="text-sm font-semibold">
<h3 class="text-sm font-semibold"> Produits inclus par défaut
Produits inclus par défaut </h3>
</h3> <p class="text-xs text-base-content/70">
<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.
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie. </p>
</p>
</div>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</header> </header>
<p v-if="!products.length" class="text-xs text-gray-500"> <p v-if="!products.length" class="text-xs text-base-content/50">
Aucun produit défini. Aucun produit défini.
</p> </p>
@@ -35,7 +29,6 @@
<select <select
v-model="product.typeProductId" v-model="product.typeProductId"
class="select select-bordered select-xs" class="select select-bordered select-xs"
:disabled="isProductLocked(product)"
@change="handleProductTypeSelect(product)" @change="handleProductTypeSelect(product)"
> >
<option value=""> <option value="">
@@ -52,39 +45,27 @@
</div> </div>
</div> </div>
<button <button
v-if="!isProductLocked(product)"
type="button" type="button"
class="btn btn-error btn-xs btn-square" class="btn btn-error btn-xs btn-square"
@click="removeProduct(index)" @click="removeProduct(index)"
> >
<IconLucideTrash class="w-4 h-4" aria-hidden="true" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> </button>
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
<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>
</li> </li>
</ul> </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>
<section class="space-y-3"> <section class="space-y-3">
<header class="flex items-center justify-between"> <h3 class="text-sm font-semibold">
<h3 class="text-sm font-semibold"> Champs personnalisés
Champs personnalisés </h3>
</h3>
<button type="button" class="btn btn-outline btn-xs" @click="addField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</header>
<p v-if="!fields.length" class="text-xs text-gray-500"> <p v-if="!fields.length" class="text-xs text-base-content/50">
Aucun champ personnalisé n'a encore été défini. Aucun champ personnalisé n'a encore été défini.
</p> </p>
@@ -101,482 +82,105 @@
@drop.prevent="onDrop(index)" @drop.prevent="onDrop(index)"
@dragend="onDragEnd" @dragend="onDragEnd"
> >
<div class="flex items-start gap-3"> <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" :disabled="isFieldLocked(field)">
<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" :disabled="isFieldLocked(field)">
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"
:disabled="isFieldLocked(field)"
/>
</div>
<button
v-if="!isFieldLocked(field)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button <button
type="button" type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
disabled 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" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> </button>
</div> </div>
</div> </li>
</li> </ul>
</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> </section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import IconLucideGripVertical from '~icons/lucide/grip-vertical' import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash' import IconLucideTrash from '~icons/lucide/trash'
import type { import type { PieceModelStructure } from '~/shared/types/inventory'
PieceModelCustomField, import { usePieceStructureEditorLogic } from '~/composables/usePieceStructureEditorLogic'
PieceModelCustomFieldType,
PieceModelProduct,
PieceModelStructure,
PieceModelStructureEditorField,
} from '~/shared/types/inventory'
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
import { useProductTypes } from '~/composables/useProductTypes'
defineOptions({ name: 'PieceModelStructureEditor' }) defineOptions({ name: 'PieceModelStructureEditor' })
type EditorField = PieceModelStructureEditorField & { uid: string }
type EditorProduct = {
uid: string
typeProductId: string
typeProductLabel: string
familyCode: string
}
const props = defineProps<{ const props = defineProps<{
modelValue?: PieceModelStructure | null modelValue?: PieceModelStructure | null
restrictedMode?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'update:modelValue', value: PieceModelStructure): void (event: 'update:modelValue', value: PieceModelStructure): void
}>() }>()
const { productTypes, loadProductTypes } = useProductTypes() const {
fields,
const ensureArray = <T,>(value: T[] | null | undefined): T[] => products,
Array.isArray(value) ? value : [] productTypeOptions,
formatProductTypeOption,
const normalizeLineEndings = (value: string): string => handleProductTypeSelect,
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n') addProduct,
removeProduct,
const safeClone = <T,>(value: T, fallback: T): T => { addField,
try { removeField,
return JSON.parse(JSON.stringify(value ?? fallback)) as T reorderClass,
} catch { onDragStart,
return JSON.parse(JSON.stringify(fallback)) as T onDragEnter,
} onDrop,
} onDragEnd,
} = usePieceStructureEditorLogic({ props, emit })
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
if (!structure || typeof structure !== 'object') {
return {}
}
const entries = Object.entries(structure).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return safeClone(Object.fromEntries(entries), {})
}
let uidCounter = 0
const createUid = (scope: 'field' | 'product'): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
uidCounter += 1
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
}
const toEditorField = (
input: Partial<PieceModelStructureEditorField> | null | undefined,
index: number,
): EditorField => {
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
const optionsText = normalizeLineEndings(
typeof input?.optionsText === 'string'
? input.optionsText
: Array.isArray(input?.options)
? input.options.join('\n')
: '',
)
return {
uid: createUid('field'),
name: typeof input?.name === 'string' ? input.name : '',
type: baseType as PieceModelCustomFieldType,
required: Boolean(input?.required),
optionsText,
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
}
}
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
const source = ensureArray(structure?.customFields)
return source
.map((field, index) => toEditorField(field, index))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
}
const toEditorProduct = (
input: Partial<PieceModelProduct> | null | undefined,
): EditorProduct => ({
uid: createUid('product'),
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
typeProductLabel:
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
})
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
const source = Array.isArray(structure?.products) ? structure?.products : []
return source.map((product) => toEditorProduct(product))
}
const productTypeOptions = computed(() => productTypes.value ?? [])
const productTypeMap = computed(() => {
const map = new Map<string, any>()
productTypeOptions.value.forEach((type: any) => {
if (type?.id) {
map.set(type.id, type)
}
})
return map
})
const formatProductTypeOption = (type: any) => {
if (!type) {
return ''
}
const parts: string[] = []
if (type.code) {
parts.push(type.code)
}
if (type.name) {
parts.push(type.name)
}
return parts.length ? parts.join(' • ') : type.id || ''
}
const updateProductTypeMetadata = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
}
const handleProductTypeSelect = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
if (option?.code) {
product.familyCode = option.code
}
}
const createEmptyProduct = (): EditorProduct => ({
uid: createUid('product'),
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
const addProduct = () => {
products.value.push(createEmptyProduct())
}
const removeProduct = (index: number) => {
products.value = products.value.filter((_, idx) => idx !== index)
}
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
const isFieldLocked = (field: EditorField): boolean => {
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
}
const isProductLocked = (product: EditorProduct): boolean => {
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
}
const restrictedMode = computed(() => props.restrictedMode === true)
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
list.map((field, index) => ({
...field,
orderIndex: index,
}))
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
if (!typeProductId && !familyCode) {
return null
}
const payload: PieceModelProduct = {}
if (typeProductId) {
payload.typeProductId = typeProductId
}
if (familyCode) {
payload.familyCode = familyCode
}
if (product.typeProductLabel) {
payload.typeProductLabel = product.typeProductLabel
}
return payload
}
const buildPayload = (
fieldsSource: EditorField[],
productsSource: EditorProduct[],
restSource: Record<string, unknown>,
): PieceModelStructure => {
const normalizedFields = fieldsSource
.map<PieceModelCustomField | null>((field, index) => {
const name = field.name.trim()
if (!name) {
return null
}
const type = (field.type || 'text') as PieceModelCustomFieldType
const required = Boolean(field.required)
const payload: PieceModelCustomField = {
name,
type,
required,
orderIndex: index,
}
if (type === 'select') {
const options = normalizeLineEndings(field.optionsText)
.split('\n')
.map((option) => option.trim())
.filter((option) => option.length > 0)
if (options.length > 0) {
payload.options = options
}
}
return payload
})
.filter((field): field is PieceModelCustomField => Boolean(field))
const normalizedProducts = productsSource
.map((product) => normalizeProductEntry(product))
.filter((product): product is PieceModelProduct => Boolean(product))
const draft: PieceModelStructure = {
...safeClone(restSource, {}),
products: normalizedProducts,
customFields: normalizedFields,
}
return normalizePieceStructureForSave(draft)
}
const serializeStructure = (structure?: PieceModelStructure | null): string => {
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
}
let lastEmitted = serializeStructure(props.modelValue)
const emitUpdate = () => {
const payload = buildPayload(fields.value, products.value, restState.value)
const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) {
lastEmitted = serialized
emit('update:modelValue', payload)
}
}
watch(fields, emitUpdate, { deep: true })
watch(products, emitUpdate, { deep: true })
watch(productTypeOptions, () => {
products.value.forEach((product) => updateProductTypeMetadata(product))
})
watch(
() => props.modelValue,
(value) => {
const incomingSerialized = serializeStructure(value)
if (incomingSerialized === lastEmitted) {
return
}
restState.value = extractRest(value)
fields.value = hydrateFields(value)
products.value = hydrateProducts(value)
products.value.forEach((product) => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
initialProductUids.value = new Set(products.value.map(p => p.uid))
},
{ deep: true },
)
onMounted(async () => {
if (!productTypeOptions.value.length) {
await loadProductTypes()
}
products.value.forEach((product) => updateProductTypeMetadata(product))
})
const dragState = reactive({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const resetDragState = () => {
dragState.draggingIndex = null
dragState.dropTargetIndex = null
}
const reorderFields = (from: number, to: number) => {
if (from === to) {
resetDragState()
return
}
const list = fields.value.slice()
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
if (!moved) {
resetDragState()
return
}
list.splice(to, 0, moved)
fields.value = applyOrderIndex(list)
resetDragState()
}
const onDragStart = (index: number, event: DragEvent) => {
dragState.draggingIndex = index
dragState.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (dragState.draggingIndex === null) {
return
}
dragState.dropTargetIndex = index
}
const onDrop = (index: number) => {
if (dragState.draggingIndex === null) {
resetDragState()
return
}
reorderFields(dragState.draggingIndex, index)
}
const onDragEnd = () => {
resetDragState()
}
const reorderClass = (index: number) => {
if (dragState.draggingIndex === index) {
return 'border-dashed border-primary bg-primary/5'
}
if (
dragState.draggingIndex !== null &&
dragState.dropTargetIndex === index &&
dragState.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/10'
}
return ''
}
const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid('field'),
name: '',
type: 'text',
required: false,
optionsText: '',
orderIndex,
})
const addField = () => {
const next = fields.value.slice()
next.push(createEmptyField(next.length))
fields.value = applyOrderIndex(next)
}
const removeField = (index: number) => {
const next = fields.value.filter((_, i) => i !== index)
fields.value = applyOrderIndex(next)
}
</script> </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

@@ -8,9 +8,11 @@
:empty-text="emptyText" :empty-text="emptyText"
size="sm" size="sm"
option-value="id" option-value="id"
option-label="name" :option-label="formatLabel"
:disabled="disabled" :disabled="disabled"
server-search
@update:modelValue="updateValue" @update:modelValue="updateValue"
@search="handleSearch"
> >
<template #option-description="{ option }"> <template #option-description="{ option }">
<span class="text-xs text-base-content/60"> <span class="text-xs text-base-content/60">
@@ -25,7 +27,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import SearchSelect from '~/components/common/SearchSelect.vue' import SearchSelect from '~/components/common/SearchSelect.vue'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
@@ -52,43 +54,45 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void (e: 'update:modelValue', value: string | null): void
}>() }>()
const { products, loading, loadProducts } = useProducts() const { loading: globalLoading, loadProducts } = useProducts()
const productOptions = computed(() => { const localProducts = ref<any[]>([])
const baseOptions = Array.isArray(products.value) ? products.value : [] const localLoading = ref(false)
if (!props.typeProductId) { const loading = computed(() => localLoading.value || globalLoading.value)
return baseOptions
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
}
}
const allowedTypeId = String(props.typeProductId) let searchDebounce: ReturnType<typeof setTimeout> | null = null
return baseOptions.filter((product) => { const handleSearch = (term: string) => {
const typeId = if (searchDebounce) clearTimeout(searchDebounce)
product?.typeProductId || searchDebounce = setTimeout(() => loadFilteredProducts(term.trim()), 300)
product?.typeProduct?.id || }
null
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => { onMounted(() => {
if (productOptions.value.length === 0) { loadFilteredProducts()
loadProducts().catch((error) => {
console.error('Erreur lors du chargement des produits:', error)
})
}
}) })
watch( watch(
() => props.modelValue, () => props.typeProductId,
(value) => { () => {
if (typeof value === 'string') { loadFilteredProducts()
const exists = productOptions.value.some((product) => product.id === value)
if (!exists && productOptions.value.length === 0 && !loading.value) {
loadProducts().catch((error) => {
console.error('Erreur lors du chargement des produits:', error)
})
}
}
}, },
) )
@@ -100,10 +104,20 @@ const updateValue = (value: string | number | null | undefined) => {
emit('update:modelValue', String(value)) 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 formatDescription = (option: any) => {
const parts: string[] = [] const parts: string[] = []
const typeName = option?.typeProduct?.name
if (typeName) {
parts.push(typeName)
}
if (option?.reference) { if (option?.reference) {
parts.push(option.reference) parts.push(`Ref. ${option.reference}`)
} }
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) { if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
const price = Number(option.supplierPrice) const price = Number(option.supplierPrice)

File diff suppressed because it is too large Load Diff

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

@@ -63,13 +63,13 @@
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) --> <!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
<template v-else> <template v-else>
<div class="overflow-x-auto relative"> <div class="overflow-x-auto overflow-y-clip relative rounded-lg border border-base-300/40">
<!-- Loading overlay (keeps table & filter inputs visible) --> <!-- Loading overlay (keeps table & filter inputs visible) -->
<div <div
v-if="loading && hasFilterableColumns" v-if="loading && hasFilterableColumns"
class="absolute inset-0 bg-base-100/50 z-10 flex items-center justify-center" class="absolute inset-0 bg-base-100/60 backdrop-blur-[1px] z-10 flex items-center justify-center"
> >
<span class="loading loading-spinner" aria-hidden="true" /> <span class="loading loading-spinner text-primary" aria-hidden="true" />
</div> </div>
<table :class="['table table-sm md:table-md', tableClass]"> <table :class="['table table-sm md:table-md', tableClass]">
<thead> <thead>
@@ -175,7 +175,7 @@
</tr> </tr>
<!-- Expanded row --> <!-- Expanded row -->
<tr v-if="expandable && isExpanded(row)"> <tr v-if="expandable && isExpanded(row)">
<td :colspan="columns.length + 1" class="bg-base-200/50 p-4"> <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" /> <slot name="row-expanded" :row="row" :index="idx" />
</td> </td>
</tr> </tr>

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

@@ -9,11 +9,11 @@
</button> </button>
</div> </div>
<p class="text-sm text-gray-500"> <p class="text-sm text-base-content/50">
{{ labels.description }} {{ labels.description }}
</p> </p>
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4"> <div v-if="requirements.length === 0" class="text-sm text-base-content/50 bg-base-200/60 rounded-md p-4">
{{ labels.emptyState }} {{ labels.emptyState }}
</div> </div>

View File

@@ -41,11 +41,11 @@
v-if="openDropdown" 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" 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-gray-500"> <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" /> <span class="loading loading-spinner loading-xs" />
Recherche en cours Recherche en cours
</div> </div>
<div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-gray-500"> <div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-base-content/50">
{{ emptyText }} {{ emptyText }}
</div> </div>
<ul v-else class="flex flex-col"> <ul v-else class="flex flex-col">
@@ -69,7 +69,7 @@
{{ resolveLabel(option) }} {{ resolveLabel(option) }}
</slot> </slot>
</span> </span>
<span v-if="resolveDescription(option)" class="text-xs text-gray-500"> <span v-if="$slots['option-description'] || resolveDescription(option)" class="text-xs text-base-content/50">
<slot name="option-description" :option="option"> <slot name="option-description" :option="option">
{{ resolveDescription(option) }} {{ resolveDescription(option) }}
</slot> </slot>
@@ -133,6 +133,10 @@ const props = defineProps({
maxVisible: { maxVisible: {
type: Number, type: Number,
default: 50 default: 50
},
serverSearch: {
type: Boolean,
default: false
} }
}) })
@@ -150,11 +154,11 @@ const selectedOption = computed(() => {
}) })
const displayedOptions = computed(() => { const displayedOptions = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
const items = baseOptions.value.slice() const items = baseOptions.value.slice()
const filtered = term const filtered = (!props.serverSearch && searchTerm.value.trim())
? items.filter((option) => { ? items.filter((option) => {
const term = searchTerm.value.trim().toLowerCase()
const label = resolveLabel(option).toLowerCase() const label = resolveLabel(option).toLowerCase()
const description = resolveDescription(option)?.toLowerCase() || '' const description = resolveDescription(option)?.toLowerCase() || ''
return label.includes(term) || description.includes(term) return label.includes(term) || description.includes(term)

View File

@@ -0,0 +1,162 @@
<template>
<div class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ description }}
</p>
</div>
<span class="badge badge-outline">{{ previewBadge }}</span>
</div>
<details v-if="structure" class="collapse collapse-arrow bg-base-100">
<summary class="collapse-title text-sm font-medium">
Consulter le détail du squelette
</summary>
<div class="collapse-content text-sm text-base-content/80" :class="variant === 'component' ? 'space-y-4' : 'space-y-2'">
<!-- Custom fields: component variant (rich display) -->
<div v-if="variant === 'component' && customFields.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="space-y-2">
<li
v-for="field in customFields"
:key="field.customFieldId || field.id || field.name"
class="rounded bg-base-200/60 px-3 py-2"
>
<p class="font-medium text-sm text-base-content">
{{ field.name || field.key }}
</p>
<p class="text-xs text-base-content/70 mt-1">
Type : {{ field.type || 'text' }}<span v-if="field.required"> &bull; Obligatoire</span>
<span v-if="Array.isArray(field.options) && field.options.length">
&bull; Options : {{ field.options.join(', ') }}
</span>
<span v-if="field.defaultValue">
&bull; Défaut : {{ field.defaultValue }}
</span>
</p>
</li>
</ul>
</div>
<!-- Custom fields: piece variant (simple display) -->
<div v-if="variant === 'piece' && customFields.length" class="space-y-1">
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
<ul class="list-disc list-inside space-y-1">
<li v-for="field in customFields" :key="field.name">
<span class="font-medium">{{ field.name }}</span>
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
</li>
</ul>
</div>
<!-- Pieces: component variant only -->
<div v-if="variant === 'component' && pieces.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces imposées</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(piece, index) in pieces"
:key="piece.role || piece.typePieceId || piece.familyCode || index"
>
{{ resolvePieceLabelFn(piece) }}
</li>
</ul>
</div>
<!-- Products: component variant only -->
<div v-if="variant === 'component' && products.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(product, index) in products"
:key="product.role || product.typeProductId || product.familyCode || index"
>
{{ resolveProductLabelFn(product) }}
</li>
</ul>
</div>
<!-- Subcomponents: component variant only -->
<div v-if="variant === 'component' && subcomponents.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<ul class="list-disc list-inside space-y-1">
<li
v-for="(subcomponent, index) in subcomponents"
:key="subcomponent.alias || subcomponent.typeComposantId || subcomponent.familyCode || index"
>
{{ resolveSubcomponentLabelFn(subcomponent) }}
</li>
</ul>
</div>
<!-- Empty state: component variant -->
<p
v-if="variant === 'component' && showEmptyState && !customFields.length && !pieces.length && !products.length && !subcomponents.length"
class="text-xs text-base-content/50"
>
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
</p>
<!-- Empty state: piece variant -->
<p v-if="variant === 'piece' && !customFields.length" class="text-xs text-base-content/70">
Ce squelette ne définit pas encore de champs personnalisés.
</p>
</div>
</details>
</div>
</template>
<script setup lang="ts">
import {
getStructureCustomFields,
getStructurePieces,
getStructureProducts,
getStructureSubcomponents,
} from '~/shared/utils/structureDisplayUtils'
const props = withDefaults(defineProps<{
structure: Record<string, any> | null
description?: string
previewBadge: string
variant: 'component' | 'piece'
showEmptyState?: boolean
resolvePieceLabel?: (piece: Record<string, any>) => string
resolveProductLabel?: (product: Record<string, any>) => string
resolveSubcomponentLabel?: (subcomponent: Record<string, any>) => string
}>(), {
description: '',
showEmptyState: false,
resolvePieceLabel: undefined,
resolveProductLabel: undefined,
resolveSubcomponentLabel: undefined,
})
const customFields = computed(() =>
getStructureCustomFields(props.structure),
)
const pieces = computed(() =>
props.variant === 'component' ? getStructurePieces(props.structure) : [],
)
const products = computed(() =>
props.variant === 'component' ? getStructureProducts(props.structure) : [],
)
const subcomponents = computed(() =>
props.variant === 'component' ? getStructureSubcomponents(props.structure) : [],
)
const fallbackLabel = (item: Record<string, any>) =>
item?.name || item?.label || item?.role || item?.alias || 'N/A'
const resolvePieceLabelFn = (piece: Record<string, any>) =>
props.resolvePieceLabel ? props.resolvePieceLabel(piece) : fallbackLabel(piece)
const resolveProductLabelFn = (product: Record<string, any>) =>
props.resolveProductLabel ? props.resolveProductLabel(product) : fallbackLabel(product)
const resolveSubcomponentLabelFn = (subcomponent: Record<string, any>) =>
props.resolveSubcomponentLabel ? props.resolveSubcomponentLabel(subcomponent) : fallbackLabel(subcomponent)
</script>

View File

@@ -0,0 +1,198 @@
<template>
<dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
<div class="modal-box max-w-lg">
<h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>
<div v-if="!preview" class="flex justify-center py-8">
<span class="loading loading-spinner loading-md" />
</div>
<template v-else>
<div class="mt-4 space-y-4">
<!-- Restore mode explanation -->
<div
class="alert text-sm"
:class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
>
<div class="flex flex-col gap-1">
<!-- FULL MODE -->
<template v-if="preview.restoreMode === 'full'">
<span class="font-semibold">Restauration complete</span>
<!-- Machine: always full, no category -->
<template v-if="entityType === 'machine'">
<span>Tous les elements de la machine seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, prix</li>
<li>Site</li>
<li>Fournisseurs</li>
<li>Composants, pieces et produits lies</li>
<li>Champs personnalises</li>
</ul>
</template>
<!-- Composant -->
<template v-else-if="entityType === 'composant'">
<span>La categorie est identique. Tous les elements du composant seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
<li>Structure : pieces, sous-composants et produits lies</li>
<li>Champs personnalises</li>
</ul>
</template>
<!-- Piece -->
<template v-else-if="entityType === 'piece'">
<span>La categorie est identique. Tous les elements de la piece seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
<li>Produits lies</li>
<li>Champs personnalises</li>
</ul>
</template>
<!-- Product -->
<template v-else-if="entityType === 'product'">
<span>La categorie est identique. Tous les elements du produit seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, prix fournisseur</li>
<li>Fournisseurs</li>
<li>Champs personnalises</li>
</ul>
</template>
</template>
<!-- PARTIAL MODE (never for machines) -->
<template v-else>
<span class="font-semibold">Restauration partielle</span>
<!-- Composant -->
<template v-if="entityType === 'composant'">
<span>La categorie du composant a change depuis cette version. Seuls les champs de base seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
</ul>
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
<ul class="ml-4 list-disc text-xs opacity-70">
<li>Structure actuelle (pieces, sous-composants, produits lies)</li>
<li>Champs personnalises actuels</li>
</ul>
</template>
<!-- Piece -->
<template v-else-if="entityType === 'piece'">
<span>La categorie de la piece a change depuis cette version. Seuls les champs de base seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, description, prix</li>
<li>Fournisseurs</li>
</ul>
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
<ul class="ml-4 list-disc text-xs opacity-70">
<li>Produits lies actuels</li>
<li>Champs personnalises actuels</li>
</ul>
</template>
<!-- Product -->
<template v-else-if="entityType === 'product'">
<span>La categorie du produit a change depuis cette version. Seuls les champs de base seront restaures :</span>
<ul class="ml-4 list-disc text-xs">
<li>Nom, reference, prix fournisseur</li>
<li>Fournisseurs</li>
</ul>
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
<ul class="ml-4 list-disc text-xs opacity-70">
<li>Champs personnalises actuels</li>
</ul>
</template>
</template>
</div>
</div>
<!-- Diff -->
<div v-if="Object.keys(preview.diff).length" class="space-y-2">
<h4 class="text-sm font-semibold">Changements qui seront appliques</h4>
<ul class="space-y-1 text-sm">
<li
v-for="(change, field) in preview.diff"
:key="field"
class="flex flex-col rounded-md border border-base-200 px-3 py-2"
>
<span class="font-medium text-base-content">{{ fieldLabels[field] || formatFieldLabel(String(field)) }}</span>
<span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
<span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
</li>
</ul>
</div>
<div v-else class="text-sm text-base-content/60">
Aucune difference detectee l'entite est deja dans l'etat de cette version.
</div>
<!-- Warnings -->
<div v-if="preview.warnings.length" class="space-y-1">
<h4 class="text-sm font-semibold text-warning">Avertissements</h4>
<ul class="space-y-1">
<li
v-for="(warning, i) in preview.warnings"
:key="i"
class="alert alert-warning py-2 text-xs"
>
{{ warning.message }}
</li>
</ul>
</div>
</div>
<div class="modal-action">
<button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
Annuler
</button>
<button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
<span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
Confirmer la restauration
</button>
</div>
</template>
</div>
<form method="dialog" class="modal-backdrop" @click="$emit('close')">
<button type="button">close</button>
</form>
</dialog>
</template>
<script setup lang="ts">
import type { RestorePreview } from '~/composables/useEntityVersions'
defineProps<{
visible: boolean
preview: RestorePreview | null
restoring: boolean
fieldLabels: Record<string, string>
entityType: 'machine' | 'composant' | 'piece' | 'product'
}>()
defineEmits<{
close: []
confirm: []
}>()
const formatFieldLabel = (field: string): string => {
if (field.startsWith('customField:')) {
return `Champ perso : ${field.replace('customField:', '')}`
}
return field
}
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '—'
if (Array.isArray(value)) {
return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
}
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
</script>

View File

@@ -23,7 +23,7 @@
@blur="onBlur" @blur="onBlur"
@focus="(event) => emit('focus', event)" @focus="(event) => emit('focus', event)"
/> />
<p v-if="help" :id="helpId" class="mt-2 text-xs text-gray-500"> <p v-if="help" :id="helpId" class="mt-2 text-xs text-base-content/50">
{{ help }} {{ help }}
</p> </p>
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error"> <p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">

View File

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

View File

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

View File

@@ -1,18 +1,28 @@
<template> <template>
<div class="navbar bg-base-100 shadow-lg"> <div class="navbar navbar-glass sticky top-0 z-50 px-4 lg:px-6">
<div class="navbar-start"> <div class="navbar-start">
<!-- Mobile hamburger menu --> <!-- Mobile hamburger menu -->
<div class="dropdown"> <div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden"> <div tabindex="0" role="button" class="btn btn-ghost btn-sm lg:hidden">
<IconLucideMenu class="w-5 h-5" aria-hidden="true" /> <IconLucideMenu class="w-5 h-5" aria-hidden="true" />
</div> </div>
<ul <ul
tabindex="0" tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52" class="menu menu-sm dropdown-content mt-3 z-[1] p-3 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
> >
<li class="pt-1 pb-2 lg:hidden"> <li class="pt-1 pb-2 lg:hidden">
<button <button
class="w-full flex items-center gap-2 rounded-md px-2 py-1 transition-colors text-base-content hover:bg-primary/10 hover:text-primary" class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
@click="toggleDarkMode"
>
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
{{ isDark ? 'Mode clair' : 'Mode sombre' }}
</button>
</li>
<li class="pt-1 pb-2 lg:hidden">
<button
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
@click="$emit('open-settings')" @click="$emit('open-settings')"
> >
<IconLucideSettings class="w-4 h-4" aria-hidden="true" /> <IconLucideSettings class="w-4 h-4" aria-hidden="true" />
@@ -24,7 +34,7 @@
<li v-for="link in simpleLinks" :key="link.to"> <li v-for="link in simpleLinks" :key="link.to">
<NuxtLink <NuxtLink
:to="link.to" :to="link.to"
class="rounded-md px-2 py-1 transition-colors flex items-center gap-2" class="rounded-lg px-3 py-2 transition-all flex items-center gap-2"
:class="linkClass(link)" :class="linkClass(link)"
> >
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" /> <component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
@@ -40,7 +50,7 @@
> >
<button <button
type="button" type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-all"
:class="groupClass(group)" :class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-mobile'" :aria-expanded="openDropdown === group.id + '-mobile'"
@click="toggleDropdown(group.id + '-mobile')" @click="toggleDropdown(group.id + '-mobile')"
@@ -52,7 +62,7 @@
{{ group.label }} {{ group.label }}
</span> </span>
<IconLucideChevronRight <IconLucideChevronRight
class="h-4 w-4 transition-transform" class="h-3.5 w-3.5 transition-transform duration-200"
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''" :class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
aria-hidden="true" aria-hidden="true"
/> />
@@ -60,12 +70,12 @@
<Transition name="nav-dropdown-mobile"> <Transition name="nav-dropdown-mobile">
<ul <ul
v-if="openDropdown === group.id + '-mobile'" v-if="openDropdown === group.id + '-mobile'"
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden" class="mt-1 space-y-0.5 rounded-lg bg-base-200/50 p-2 overflow-hidden"
> >
<li v-for="child in group.children" :key="child.to"> <li v-for="child in group.children" :key="child.to">
<NuxtLink <NuxtLink
:to="child.to" :to="child.to"
class="rounded-md px-2 py-1 transition-colors block" class="rounded-md px-3 py-1.5 transition-colors block text-sm"
:class="childLinkClass(child)" :class="childLinkClass(child)"
> >
{{ child.label }} {{ child.label }}
@@ -81,30 +91,28 @@
</div> </div>
<!-- Logo --> <!-- Logo -->
<div class="flex items-center space-x-3"> <NuxtLink to="/" class="flex items-center gap-2.5 group">
<div class="avatar"> <div class="w-9 h-9 rounded-lg overflow-hidden ring-1 ring-base-300/50 transition-all group-hover:ring-primary/30 group-hover:shadow-md">
<div class="w-14"> <img
<img :src="logoSrc"
:src="logoSrc" alt="Logo Malio"
alt="Logo Malio" class="h-full w-full object-contain"
class="h-full w-full object-contain" />
/>
</div>
</div> </div>
<NuxtLink to="/" class="btn btn-ghost text-xl"> <span class="text-lg font-bold tracking-tight text-base-content hidden sm:inline" style="font-family: var(--font-heading)">
Inventory Inventory
</NuxtLink> </span>
</div> </NuxtLink>
</div> </div>
<!-- Desktop navbar --> <!-- Desktop navbar -->
<div class="navbar-center hidden lg:flex"> <div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1"> <ul class="menu menu-horizontal gap-0.5 px-1">
<!-- Desktop: simple links --> <!-- Desktop: simple links -->
<li v-for="link in simpleLinks" :key="link.to"> <li v-for="link in simpleLinks" :key="link.to">
<NuxtLink <NuxtLink
:to="link.to" :to="link.to"
class="transition-colors px-3 py-2 rounded-md flex items-center gap-1.5" class="transition-all px-3 py-2 rounded-lg flex items-center gap-1.5 text-sm font-medium"
:class="linkClass(link)" :class="linkClass(link)"
> >
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" /> <component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
@@ -124,7 +132,7 @@
> >
<button <button
type="button" type="button"
class="inline-flex items-center gap-1.5 rounded-md px-3 py-2 transition-colors" class="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 transition-all text-sm font-medium"
:class="groupClass(group)" :class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-desktop'" :aria-expanded="openDropdown === group.id + '-desktop'"
@click="toggleDropdown(group.id + '-desktop')" @click="toggleDropdown(group.id + '-desktop')"
@@ -133,21 +141,21 @@
> >
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" /> <component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }} {{ group.label }}
<IconLucideChevronRight <IconLucideChevronDown
class="h-4 w-4 transition-transform" class="h-3.5 w-3.5 transition-transform duration-200"
:class="openDropdown === group.id + '-desktop' ? 'rotate-90' : ''" :class="openDropdown === group.id + '-desktop' ? 'rotate-180' : ''"
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
<Transition name="nav-dropdown-desktop"> <Transition name="nav-dropdown-desktop">
<ul <ul
v-if="openDropdown === group.id + '-desktop'" v-if="openDropdown === group.id + '-desktop'"
class="absolute left-0 top-full mt-2 w-64 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50" class="absolute left-0 top-full mt-1.5 w-56 rounded-xl border border-base-300/50 bg-base-100 p-1.5 shadow-lg shadow-base-content/5 z-50"
> >
<li v-for="child in group.children" :key="child.to"> <li v-for="child in group.children" :key="child.to">
<NuxtLink <NuxtLink
:to="child.to" :to="child.to"
class="block rounded-md px-2 py-1 transition-colors" class="block rounded-lg px-3 py-2 transition-all text-sm"
:class="childLinkClass(child)" :class="childLinkClass(child)"
> >
{{ child.label }} {{ child.label }}
@@ -164,13 +172,21 @@
<!-- Navbar end --> <!-- Navbar end -->
<div class="navbar-end"> <div class="navbar-end">
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5">
<button <button
class="btn btn-ghost btn-circle hidden lg:inline-flex" class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
:title="isDark ? 'Mode clair' : 'Mode sombre'"
@click="toggleDarkMode"
>
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
</button>
<button
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
title="Paramètres d'affichage" title="Paramètres d'affichage"
@click="$emit('open-settings')" @click="$emit('open-settings')"
> >
<IconLucideSettings class="w-5 h-5" aria-hidden="true" /> <IconLucideSettings class="w-4 h-4" aria-hidden="true" />
</button> </button>
<ClientOnly> <ClientOnly>
@@ -178,7 +194,7 @@
<div <div
tabindex="0" tabindex="0"
role="button" role="button"
class="btn btn-ghost btn-circle avatar placeholder indicator" class="indicator cursor-pointer"
> >
<span <span
v-if="unresolvedCount > 0" v-if="unresolvedCount > 0"
@@ -187,47 +203,49 @@
{{ unresolvedCount }} {{ unresolvedCount }}
</span> </span>
<div <div
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center" class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
> >
<span <span class="text-xs font-semibold">
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight"
>
{{ activeProfileInitials }} {{ activeProfileInitials }}
</span> </span>
</div> </div>
</div> </div>
<ul <ul
tabindex="0" tabindex="0"
class="menu dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-64" class="menu dropdown-content mt-3 p-2 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
> >
<li class="px-2 py-1 text-sm text-base-content/70"> <li class="px-3 py-2">
Connecté en tant que<br /> <div class="flex flex-col gap-1 pointer-events-none">
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span> <span class="text-xs text-base-content/50">Connecté en tant que</span>
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span> <span class="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
</div>
</li> </li>
<div class="divider my-0.5 px-2" />
<li v-if="isAdmin"> <li v-if="isAdmin">
<NuxtLink to="/admin" class="justify-between"> <NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
Administration Administration
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" /> <IconLucideChevronRight class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink> </NuxtLink>
</li> </li>
<li> <li>
<NuxtLink to="/comments" class="justify-between"> <NuxtLink to="/comments" class="rounded-lg justify-between text-sm">
Commentaires Commentaires
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs"> <span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
{{ unresolvedCount }} {{ unresolvedCount }}
</span> </span>
<IconLucideChevronRight v-else class="w-4 h-4" aria-hidden="true" /> <IconLucideChevronRight v-else class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink> </NuxtLink>
</li> </li>
<div class="divider my-0.5 px-2" />
<li> <li>
<button <button
type="button" type="button"
class="text-error justify-between" class="rounded-lg text-error/80 hover:text-error hover:bg-error/5 justify-between text-sm"
@click="$emit('logout')" @click="$emit('logout')"
> >
Déconnexion Déconnexion
<IconLucideLogOut class="w-4 h-4" aria-hidden="true" /> <IconLucideLogOut class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</li> </li>
</ul> </ul>
@@ -248,6 +266,7 @@ import { useComments } from '~/composables/useComments'
import IconLucideMenu from '~icons/lucide/menu' import IconLucideMenu from '~icons/lucide/menu'
import IconLucideSettings from '~icons/lucide/settings' import IconLucideSettings from '~icons/lucide/settings'
import IconLucideChevronRight from '~icons/lucide/chevron-right' import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideLogOut from '~icons/lucide/log-out' import IconLucideLogOut from '~icons/lucide/log-out'
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard' import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import IconLucideFactory from '~icons/lucide/factory' import IconLucideFactory from '~icons/lucide/factory'
@@ -256,6 +275,8 @@ import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePuzzle from '~icons/lucide/puzzle' import IconLucidePuzzle from '~icons/lucide/puzzle'
import IconLucidePackage from '~icons/lucide/package' import IconLucidePackage from '~icons/lucide/package'
import IconLucideLink from '~icons/lucide/link' import IconLucideLink from '~icons/lucide/link'
import IconLucideSun from '~icons/lucide/sun'
import IconLucideMoon from '~icons/lucide/moon'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png' import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
defineEmits<{ defineEmits<{
@@ -333,6 +354,7 @@ const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = use
const { activeProfile } = useProfileSession() const { activeProfile } = useProfileSession()
const { isAdmin, canEdit } = usePermissions() const { isAdmin, canEdit } = usePermissions()
const { fetchUnresolvedCount } = useComments() const { fetchUnresolvedCount } = useComments()
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
const unresolvedCount = ref(0) const unresolvedCount = ref(0)
let pollInterval: ReturnType<typeof setInterval> | null = null let pollInterval: ReturnType<typeof setInterval> | null = null
@@ -343,6 +365,7 @@ const refreshUnresolvedCount = async () => {
} }
onMounted(() => { onMounted(() => {
initDarkMode()
refreshUnresolvedCount() refreshUnresolvedCount()
pollInterval = setInterval(refreshUnresolvedCount, 60_000) pollInterval = setInterval(refreshUnresolvedCount, 60_000)
}) })
@@ -365,19 +388,19 @@ const isGroupActive = (group: NavGroup) => {
const linkClass = (link: NavLink) => { const linkClass = (link: NavLink) => {
return isActive(link.to) return isActive(link.to)
? 'bg-primary text-primary-content font-semibold shadow-sm' ? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary' : 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
} }
const groupClass = (group: NavGroup) => { const groupClass = (group: NavGroup) => {
return isGroupActive(group) return isGroupActive(group)
? 'bg-primary text-primary-content font-semibold shadow-sm' ? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary' : 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
} }
const childLinkClass = (child: NavLink) => { const childLinkClass = (child: NavLink) => {
return isActive(child.to) return isActive(child.to)
? 'bg-primary/10 text-primary font-semibold' ? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary' : 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
} }
const roleLabel = computed(() => { const roleLabel = computed(() => {
@@ -418,12 +441,12 @@ const activeProfileInitials = computed(() => {
.nav-dropdown-desktop-enter-from, .nav-dropdown-desktop-enter-from,
.nav-dropdown-desktop-leave-to { .nav-dropdown-desktop-leave-to {
opacity: 0; opacity: 0;
transform: translateY(0.25rem); transform: translateY(4px) scale(0.98);
} }
.nav-dropdown-desktop-enter-to, .nav-dropdown-desktop-enter-to,
.nav-dropdown-desktop-leave-from { .nav-dropdown-desktop-leave-from {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0) scale(1);
} }
.nav-dropdown-mobile-enter-active, .nav-dropdown-mobile-enter-active,

View File

@@ -44,6 +44,8 @@
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`" :empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
:option-label="entityOptionLabel" :option-label="entityOptionLabel"
:option-description="entityOptionDescription" :option-description="entityOptionDescription"
server-search
@search="handleEntitySearch"
/> />
</div> </div>
@@ -145,7 +147,10 @@ const selectedTypeName = computed(() => {
return found?.name || '' return found?.name || ''
}) })
const entityOptionLabel = (e: any) => e.name || '(sans nom)' const entityOptionLabel = (e: any) => {
const name = e.name || '(sans nom)'
return e.reference ? `${name}${e.reference}` : name
}
const entityOptionDescription = (e: any) => e.reference || '' const entityOptionDescription = (e: any) => e.reference || ''
const selectedEntitySummary = computed(() => { const selectedEntitySummary = computed(() => {
@@ -187,6 +192,30 @@ watch(selectedTypeId, async () => {
} }
}) })
let searchDebounce: ReturnType<typeof setTimeout> | null = null
const handleEntitySearch = (term: string) => {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(async () => {
if (!selectedTypeName.value) return
loadingEntities.value = true
try {
if (props.entityKind === 'component') {
const result = await loadComposants({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else if (props.entityKind === 'piece') {
const result = await loadPieces({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
entities.value = result?.data?.items || []
} else {
const result = await loadProducts({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
entities.value = result?.data?.items || []
}
} finally {
loadingEntities.value = false
}
}, 300)
}
const handleClose = () => { const handleClose = () => {
resetState() resetState()
emit('close') emit('close')

View File

@@ -1,33 +1,23 @@
<template> <template>
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2> <h2 class="card-title">Composants</h2>
<div class="flex items-center gap-2"> <button
<button type="button"
v-if="isEditMode" class="btn btn-ghost btn-sm gap-2"
type="button" @click="$emit('toggle-collapse')"
class="btn btn-sm md:btn-md btn-primary" :title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
@click="$emit('add-component')" >
> <IconLucideChevronRight
Ajouter un composant class="w-5 h-5 transition-transform"
</button> :class="collapsed ? 'rotate-0' : 'rotate-90'"
<button aria-hidden="true"
type="button" />
class="btn btn-ghost btn-sm gap-2" <span class="text-sm">
@click="$emit('toggle-collapse')" {{ collapsed ? 'Tout déplier' : 'Tout replier' }}
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'" </span>
> </button>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
</div> </div>
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4"> <div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
@@ -35,25 +25,27 @@
</div> </div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<div v-for="component in components" :key="component.id" class="relative"> <div v-for="component in components" :key="component.id">
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
title="Supprimer ce composant"
@click="$emit('remove-component', component.linkId || component.id)"
>
Supprimer
</button>
<ComponentHierarchy <ComponentHierarchy
:components="[component]" :components="[component]"
:is-edit-mode="false" :is-edit-mode="false"
:show-delete="isEditMode"
:collapse-all="collapsed" :collapse-all="collapsed"
:toggle-token="collapseToggleToken" :toggle-token="collapseToggleToken"
@edit-piece="$emit('edit-piece', $event)" @edit-piece="$emit('edit-piece', $event)"
@delete="$emit('remove-component', component.linkId || component.id)"
/> />
</div> </div>
</div> </div>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-component')"
>
Ajouter un composant
</button>
</div> </div>
</div> </div>
</template> </template>

View File

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

View File

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

View File

@@ -32,6 +32,9 @@
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" /> <IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
Imprimer Imprimer
</button> </button>
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
Retour aux machines
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -41,6 +44,8 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye' import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer' import IconLucidePrinter from '~icons/lucide/printer'
const router = useRouter()
defineProps<{ defineProps<{
title: string title: string
isEditMode: boolean isEditMode: boolean
@@ -50,4 +55,13 @@ defineEmits<{
'toggle-edit': [] 'toggle-edit': []
'open-print': [] 'open-print': []
}>() }>()
function goBack() {
if (window.history.length > 1) {
router.back()
}
else {
navigateTo('/machines')
}
}
</script> </script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="card bg-base-100 shadow-lg mt-6"> <div class="card bg-base-100 shadow-sm mt-6">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -74,7 +74,7 @@
<button <button
v-if="isEditMode" v-if="isEditMode"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-ghost btn-xs text-error"
:disabled="uploading" :disabled="uploading"
@click="$emit('remove', doc.id)" @click="$emit('remove', doc.id)"
> >

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">Informations de la machine</h2> <h2 class="card-title tracking-tight">Informations de la machine</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -14,12 +14,34 @@
type="text" type="text"
class="input input-bordered" class="input input-bordered"
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)" @input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur-field')"
/> />
<div v-else class="input input-bordered bg-base-200"> <div v-else class="input input-bordered bg-base-200">
{{ machineName }} {{ machineName }}
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Site</span>
</label>
<select
v-if="isEditMode"
:value="machineSiteId"
class="select select-bordered"
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value)"
>
<option value="">Sélectionner un site</option>
<option
v-for="site in sites"
:key="site.id"
:value="site.id"
>
{{ site.name }}
</option>
</select>
<div v-else class="input input-bordered bg-base-200">
{{ machineSiteName || 'Non défini' }}
</div>
</div>
<div v-if="isEditMode || machineReference" class="form-control"> <div v-if="isEditMode || machineReference" class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Référence</span> <span class="label-text">Référence</span>
@@ -31,13 +53,12 @@
type="text" type="text"
class="input input-bordered" class="input input-bordered"
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)" @input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur-field')"
/> />
<div v-else class="input input-bordered bg-base-200"> <div v-else class="input input-bordered bg-base-200">
{{ machineReference }} {{ machineReference }}
</div> </div>
</div> </div>
<div v-if="isEditMode || hasMachineConstructeur" class="form-control"> <div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
<label class="label"> <label class="label">
<span class="label-text">Fournisseur</span> <span class="label-text">Fournisseur</span>
</label> </label>
@@ -49,30 +70,22 @@
placeholder="Rechercher un ou plusieurs fournisseurs..." placeholder="Rechercher un ou plusieurs fournisseurs..."
@update:modelValue="$emit('update:constructeur-ids', $event)" @update:modelValue="$emit('update:constructeur-ids', $event)"
/> />
<div v-else class="input input-bordered bg-base-200"> <ConstructeurLinksTable
<div v-if="machineConstructeursDisplay.length" class="space-y-1"> v-if="constructeurLinks.length"
<div :model-value="constructeurLinks"
v-for="constructeur in machineConstructeursDisplay" :readonly="!isEditMode"
:key="constructeur.id" @update:model-value="$emit('update:constructeur-links', $event)"
class="flex flex-col" @remove="$emit('remove-constructeur-link', $event)"
> />
<span class="font-medium">{{ constructeur.name }}</span> <div v-else-if="!isEditMode" class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
<span <span class="text-base-content/50">Non défini</span>
v-if="formatConstructeurContactSummary(constructeur)"
class="text-xs text-gray-500"
>
{{ formatConstructeurContactSummary(constructeur) }}
</span>
</div>
</div>
<span v-else class="font-medium">Non défini</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Champs personnalisés --> <!-- Champs personnalisés -->
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-gray-200"> <div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-base-200">
<h4 class="font-semibold text-gray-700 mb-3">Champs personnalisés de la machine</h4> <h4 class="font-semibold text-base-content/80 mb-3">Champs personnalisés de la machine</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div <div
v-for="field in visibleCustomFields" v-for="field in visibleCustomFields"
@@ -92,7 +105,6 @@
class="input input-bordered input-sm" class="input input-bordered input-sm"
:required="field.required" :required="field.required"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)" @input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/> />
<input <input
v-else-if="field.type === 'number'" v-else-if="field.type === 'number'"
@@ -101,7 +113,6 @@
class="input input-bordered input-sm" class="input input-bordered input-sm"
:required="field.required" :required="field.required"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)" @input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/> />
<select <select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
@@ -109,7 +120,6 @@
class="select select-bordered select-sm" class="select select-bordered select-sm"
:required="field.required" :required="field.required"
@change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)" @change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)"
@blur="$emit('update-custom-field', field)"
> >
<option value="">Sélectionner...</option> <option value="">Sélectionner...</option>
<option <option
@@ -126,7 +136,6 @@
class="toggle toggle-primary toggle-sm" class="toggle toggle-primary toggle-sm"
:checked="String(field.value).toLowerCase() === 'true'" :checked="String(field.value).toLowerCase() === 'true'"
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')" @change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
@blur="$emit('update-custom-field', field)"
> >
<span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
</label> </label>
@@ -137,7 +146,6 @@
class="input input-bordered input-sm" class="input input-bordered input-sm"
:required="field.required" :required="field.required"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)" @input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/> />
<div v-else class="text-xs text-error"> <div v-else class="text-xs text-error">
Type de champ non pris en charge Type de champ non pris en charge
@@ -151,34 +159,72 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="isEditMode" class="mt-6 pt-4 border-t border-base-200">
<MachineCustomFieldDefEditor
:fields="fieldDefs.fields.value"
:saving="fieldDefs.saving.value"
:reorder-class="fieldDefs.reorderClass"
:on-drag-start="fieldDefs.onDragStart"
:on-drag-enter="fieldDefs.onDragEnter"
:on-drop="fieldDefs.onDrop"
:on-drag-end="fieldDefs.onDragEnd"
@add-field="fieldDefs.addField()"
@remove-field="fieldDefs.removeField($event)"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watch } from 'vue'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue' import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import { import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
formatConstructeurContact as formatConstructeurContactSummary, import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
} from '~/shared/constructeurUtils'
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils' import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
defineProps<{ const props = defineProps<{
isEditMode: boolean isEditMode: boolean
machineName: string machineName: string
machineReference: string machineReference: string
machineSiteId: string
machineSiteName: string
sites: any[]
machineConstructeurIds: string[] machineConstructeurIds: string[]
machineConstructeursDisplay: any[] machineConstructeursDisplay: any[]
hasMachineConstructeur: boolean hasMachineConstructeur: boolean
constructeurLinks: ConstructeurLinkEntry[]
visibleCustomFields: any[] visibleCustomFields: any[]
getMachineFieldId: (fieldName: string) => string getMachineFieldId: (fieldName: string) => string
machineId: string
machineCustomFieldDefs: any[]
}>() }>()
defineEmits<{ const emit = defineEmits<{
'update:machine-name': [value: string] 'update:machine-name': [value: string]
'update:machine-reference': [value: string] 'update:machine-reference': [value: string]
'update:machine-site-id': [value: string]
'update:constructeur-ids': [ids: unknown] 'update:constructeur-ids': [ids: unknown]
'blur-field': [] 'update:constructeur-links': [links: ConstructeurLinkEntry[]]
'remove-constructeur-link': [constructeurId: string]
'set-custom-field-value': [field: any, value: unknown] 'set-custom-field-value': [field: any, value: unknown]
'update-custom-field': [field: any] 'custom-fields-saved': []
}>() }>()
const fieldDefs = useMachineCustomFieldDefs({
machineId: props.machineId,
initialDefs: props.machineCustomFieldDefs,
onSaved: () => emit('custom-fields-saved'),
})
watch(() => props.machineCustomFieldDefs, (newDefs) => {
fieldDefs.reinit(newDefs)
}, { deep: true })
defineExpose({
saveFieldDefinitions: () => fieldDefs.saveDefinitions(),
})
</script> </script>

View File

@@ -1,33 +1,23 @@
<template> <template>
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2> <h2 class="card-title">Pièces de la machine</h2>
<div class="flex items-center gap-2"> <button
<button type="button"
v-if="isEditMode" class="btn btn-ghost btn-sm gap-2"
type="button" @click="$emit('toggle-collapse')"
class="btn btn-sm md:btn-md btn-primary" :title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
@click="$emit('add-piece')" >
> <IconLucideChevronRight
Ajouter une pièce class="w-5 h-5 transition-transform"
</button> :class="collapsed ? 'rotate-0' : 'rotate-90'"
<button aria-hidden="true"
type="button" />
class="btn btn-ghost btn-sm gap-2" <span class="text-sm">
@click="$emit('toggle-collapse')" {{ collapsed ? 'Tout déplier' : 'Tout replier' }}
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'" </span>
> </button>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
</div> </div>
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4"> <div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
@@ -35,25 +25,28 @@
</div> </div>
<div v-else class="space-y-2"> <div v-else class="space-y-2">
<div v-for="piece in pieces" :key="piece.id" class="relative"> <div v-for="piece in pieces" :key="piece.id">
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2 z-10"
title="Supprimer cette pièce"
@click="$emit('remove-piece', piece.linkId || piece.id)"
>
Supprimer
</button>
<PieceItem <PieceItem
:piece="piece" :piece="piece"
:is-edit-mode="false" :is-edit-mode="isEditMode"
:show-delete="isEditMode"
:collapse-all="collapsed" :collapse-all="collapsed"
:toggle-token="collapseToggleToken" :toggle-token="collapseToggleToken"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)" @edit="$emit('edit-piece', $event)"
@delete="$emit('remove-piece', piece.linkId || piece.id)"
/> />
</div> </div>
</div> </div>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-piece')"
>
Ajouter une pièce
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -70,6 +63,7 @@ defineProps<{
defineEmits<{ defineEmits<{
'toggle-collapse': [] 'toggle-collapse': []
'update-piece': [piece: any]
'edit-piece': [piece: any] 'edit-piece': [piece: any]
'add-piece': [] 'add-piece': []
'remove-piece': [linkId: string] 'remove-piece': [linkId: string]

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
@@ -15,43 +15,35 @@
Produits sélectionnés directement pour cette machine. Produits sélectionnés directement pour cette machine.
</p> </p>
</div> </div>
<div class="flex items-center gap-2"> <span class="badge badge-outline" v-if="products.length">
<button {{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
v-if="isEditMode" </span>
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-product')"
>
Ajouter un produit
</button>
<span class="badge badge-outline" v-if="products.length">
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
</span>
</div>
</div> </div>
<div v-if="products.length" class="space-y-3"> <div v-if="products.length" class="space-y-3">
<div <div
v-for="product in products" v-for="product in products"
:key="product.id || product.name" :key="product.id || product.name"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2 relative" class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2"
> >
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs absolute top-2 right-2"
title="Supprimer ce produit"
@click="$emit('remove-product', (product.linkId || product.id) as string)"
>
Supprimer
</button>
<div class="flex items-center justify-between flex-wrap gap-2"> <div class="flex items-center justify-between flex-wrap gap-2">
<p class="font-semibold text-base-content"> <p class="font-semibold text-base-content">
{{ product.name }} {{ product.name }}
</p> </p>
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm"> <div class="flex items-center gap-2">
{{ product.groupLabel }} <span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
</span> {{ product.groupLabel }}
</span>
<button
v-if="isEditMode"
type="button"
class="btn btn-ghost btn-xs text-error"
title="Supprimer ce produit"
@click="$emit('remove-product', (product.linkId || product.id) as string)"
>
Supprimer
</button>
</div>
</div> </div>
<p v-if="product.reference" class="text-xs text-base-content/70"> <p v-if="product.reference" class="text-xs text-base-content/70">
<span class="font-medium">Référence :</span> <span class="font-medium">Référence :</span>
@@ -117,6 +109,15 @@
<p v-else class="text-xs text-gray-500"> <p v-else class="text-xs text-gray-500">
Aucun produit n'a été associé directement à cette machine. Aucun produit n'a été associé directement à cette machine.
</p> </p>
<button
v-if="isEditMode"
type="button"
class="btn btn-sm md:btn-md btn-primary"
@click="$emit('add-product')"
>
Ajouter un produit
</button>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -99,66 +99,19 @@
</template> </template>
</DataTable> </DataTable>
<ModelTypesConversionModal <ConversionModal
:open="conversionModalOpen" :open="conversionModalOpen"
:model-type="conversionTarget" :model-type="conversionTarget"
@close="closeConversionModal" @close="closeConversionModal"
@converted="onConverted" @converted="onConverted"
/> />
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }"> <RelatedItemsModal
<div class="modal-box max-w-3xl"> :open="relatedModalOpen"
<h3 class="text-lg font-bold text-base-content"> :model-type="relatedType"
{{ relatedModalTitle }} @close="relatedModalOpen = false"
</h3> @open-edit="openRelatedEdit"
<p class="mt-1 text-sm text-base-content/70"> />
{{ relatedModalSubtitle }}
</p>
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement des éléments liés
</div>
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
{{ relatedError }}
</div>
<div
v-else-if="relatedItems.length === 0"
class="px-4 py-6 text-sm text-base-content/60"
>
Aucun élément lié à cette catégorie.
</div>
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
<li
v-for="entry in relatedItems"
:key="entry.id"
class="px-2 py-1"
>
<button
type="button"
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@click="openRelatedEdit(entry)"
>
<span class="font-medium text-base-content">{{ entry.name }}</span>
<span v-if="entry.reference" class="text-xs text-base-content/60">
Référence: {{ entry.reference }}
</span>
</button>
</li>
</ul>
</div>
<div class="modal-action">
<button type="button" class="btn" @click="closeRelatedModal">
Fermer
</button>
</div>
</div>
</dialog>
</main> </main>
</template> </template>
@@ -166,10 +119,8 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import { useHead, useRouter } from '#imports' import { useHead, useRouter } from '#imports'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import ModelTypesConversionModal from '~/components/model-types/ConversionModal.vue' import ConversionModal from '~/components/model-types/ConversionModal.vue'
import { useApi } from '~/composables/useApi'
import { useUrlState } from '~/composables/useUrlState' import { useUrlState } from '~/composables/useUrlState'
import { extractCollection } from '~/shared/utils/apiHelpers'
import type { DataTableSort } from '~/shared/types/dataTable' import type { DataTableSort } from '~/shared/types/dataTable'
import { import {
deleteModelType, deleteModelType,
@@ -233,7 +184,6 @@ let activeController: AbortController | null = null
const router = useRouter() const router = useRouter()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const { get } = useApi()
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const headingText = computed(() => props.heading) const headingText = computed(() => props.heading)
@@ -422,106 +372,21 @@ const confirmDelete = async (item: ModelType) => {
} }
} }
type RelatedEntry = {
id: string
name: string
reference?: string | null
}
const relatedModalOpen = ref(false) const relatedModalOpen = ref(false)
const relatedLoading = ref(false)
const relatedError = ref<string | null>(null)
const relatedItems = ref<RelatedEntry[]>([])
const relatedType = ref<ModelType | null>(null) const relatedType = ref<ModelType | null>(null)
const relatedCategoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
COMPONENT: { plural: 'composants', singular: 'composant' },
PIECE: { plural: 'pièces', singular: 'pièce' },
PRODUCT: { plural: 'produits', singular: 'produit' },
}
const relatedModalTitle = computed(() => {
const current = relatedType.value
if (!current) return 'Éléments liés'
return `Éléments liés à « ${current.name} »`
})
const relatedModalSubtitle = computed(() => {
const current = relatedType.value
if (!current) return ''
const labels = relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT
const count = relatedItems.value.length
if (relatedLoading.value) return `Chargement des ${labels.plural}`
if (count === 0) return `Aucun ${labels.singular} lié.`
if (count === 1) return `1 ${labels.singular} lié.`
return `${count} ${labels.plural} liés.`
})
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`
const resolveRelatedConfig = (category: ModelCategory) => {
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
return { endpoint: '/products', filterKey: 'typeProduct' }
}
const resolveRelatedEditBasePath = (category: ModelCategory) => { const resolveRelatedEditBasePath = (category: ModelCategory) => {
if (category === 'COMPONENT') return '/component' if (category === 'COMPONENT') return '/component'
if (category === 'PIECE') return '/pieces' if (category === 'PIECE') return '/pieces'
return '/product' return '/product'
} }
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
if (!item || typeof item !== 'object') return null
const record = item as Record<string, unknown>
if (typeof record.id !== 'string') return null
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
const reference
= typeof record.reference === 'string' && record.reference.trim()
? record.reference
: typeof record.code === 'string' && record.code.trim()
? record.code
: null
return { id: record.id, name, reference }
}
const loadRelatedItems = async (item: ModelType) => {
const { endpoint, filterKey } = resolveRelatedConfig(item.category)
const params = new URLSearchParams()
params.set('itemsPerPage', '200')
params.set(filterKey, buildModelTypeIri(item.id))
params.set('order[name]', 'asc')
relatedLoading.value = true
relatedError.value = null
relatedItems.value = []
try {
const result = await get(`${endpoint}?${params.toString()}`)
if (!result.success) {
relatedError.value = result.error ?? 'Impossible de charger les éléments liés.'
return
}
const collection = extractCollection(result.data)
relatedItems.value = collection
.map(mapRelatedEntry)
.filter((entry): entry is RelatedEntry => Boolean(entry))
}
catch (error) {
relatedError.value = extractErrorMessage(error)
}
finally {
relatedLoading.value = false
}
}
const openRelatedModal = (item: ModelType) => { const openRelatedModal = (item: ModelType) => {
relatedType.value = item relatedType.value = item
relatedModalOpen.value = true relatedModalOpen.value = true
void loadRelatedItems(item)
} }
const openRelatedEdit = (entry: RelatedEntry) => { const openRelatedEdit = (entry: { id: string }) => {
const current = relatedType.value const current = relatedType.value
if (!current) return if (!current) return
const basePath = resolveRelatedEditBasePath(current.category) const basePath = resolveRelatedEditBasePath(current.category)
@@ -531,10 +396,6 @@ const openRelatedEdit = (entry: RelatedEntry) => {
}) })
} }
const closeRelatedModal = () => {
relatedModalOpen.value = false
}
const conversionModalOpen = ref(false) const conversionModalOpen = ref(false)
const conversionTarget = ref<ModelType | null>(null) const conversionTarget = ref<ModelType | null>(null)

View File

@@ -15,8 +15,7 @@
minlength="2" minlength="2"
maxlength="120" maxlength="120"
required required
:disabled="restrictedMode" />
/>
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p> <p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
</div> </div>
<div> <div>
@@ -48,7 +47,6 @@
rows="4" rows="4"
name="notes" name="notes"
maxlength="2000" maxlength="2000"
:disabled="restrictedMode"
></textarea> ></textarea>
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p> <p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
</div> </div>
@@ -83,7 +81,6 @@
v-model="componentStructure" v-model="componentStructure"
:allow-subcomponents="allowComponentSubcomponents" :allow-subcomponents="allowComponentSubcomponents"
:max-subcomponent-depth="componentSubcomponentMaxDepth" :max-subcomponent-depth="componentSubcomponentMaxDepth"
:restricted-mode="restrictedMode"
/> />
</div> </div>
@@ -95,7 +92,7 @@
Aperçu : Aperçu :
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span> <span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
</p> </p>
<PieceModelStructureEditor v-model="pieceStructure" :restricted-mode="restrictedMode" /> <PieceModelStructureEditor v-model="pieceStructure" />
</div> </div>
<div <div
@@ -106,29 +103,17 @@
Aperçu : Aperçu :
<span class="font-medium text-base-content">{{ productStructurePreview }}</span> <span class="font-medium text-base-content">{{ productStructurePreview }}</span>
</p> </p>
<PieceModelStructureEditor v-model="productStructure" :restricted-mode="restrictedMode" /> <PieceModelStructureEditor v-model="productStructure" />
</div> </div>
</template> </template>
</section> </section>
<div <ReferenceFormulaBuilder
v-if="restrictedMode && restrictedModeMessage" v-if="form.category === 'PIECE' || form.category === 'COMPONENT'"
class="alert alert-info" v-model="form.referenceFormula"
role="status" :custom-fields="formulaBuilderCustomFields"
aria-live="polite" :disabled="isReadonly"
> />
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>{{ restrictedModeMessage }}</span>
</div>
<div
v-if="disableSubmit"
class="alert alert-warning"
role="alert"
aria-live="polite"
>
<span>{{ disableSubmitMessage }}</span>
</div>
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end"> <footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')"> <button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
@@ -172,10 +157,6 @@ const props = withDefaults(defineProps<{
structureLoading?: boolean structureLoading?: boolean
allowComponentSubcomponents?: boolean allowComponentSubcomponents?: boolean
componentSubcomponentMaxDepth?: number componentSubcomponentMaxDepth?: number
disableSubmit?: boolean
disableSubmitMessage?: string
restrictedMode?: boolean
restrictedModeMessage?: string
readonly?: boolean readonly?: boolean
}>(), { }>(), {
initialData: null, initialData: null,
@@ -184,10 +165,6 @@ const props = withDefaults(defineProps<{
structureLoading: false, structureLoading: false,
allowComponentSubcomponents: true, allowComponentSubcomponents: true,
componentSubcomponentMaxDepth: 1, componentSubcomponentMaxDepth: 1,
disableSubmit: false,
disableSubmitMessage: '',
restrictedMode: false,
restrictedModeMessage: '',
readonly: false, readonly: false,
}) })
@@ -205,28 +182,35 @@ const componentSubcomponentMaxDepth = computed(() =>
? props.componentSubcomponentMaxDepth ? props.componentSubcomponentMaxDepth
: 1, : 1,
) )
const disableSubmit = computed(() => props.disableSubmit === true)
const disableSubmitMessage = computed(() =>
(props.disableSubmitMessage && props.disableSubmitMessage.trim())
? props.disableSubmitMessage
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
)
const isReadonly = computed(() => props.readonly === true) const isReadonly = computed(() => props.readonly === true)
const restrictedMode = computed(() => props.restrictedMode === true || isReadonly.value)
const restrictedModeMessage = computed(() =>
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
? props.restrictedModeMessage
: '',
)
const form = reactive<ModelTypePayload>({ const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
name: '', name: '',
code: '', code: '',
category: props.initialCategory, category: props.initialCategory,
notes: '', notes: '',
structure: undefined, structure: undefined,
referenceFormula: null,
}) })
const formulaBuilderCustomFields = computed(() => {
if (form.category === 'PIECE') {
const fields = pieceStructure.value?.customFields
return Array.isArray(fields) ? fields : []
}
if (form.category === 'COMPONENT') {
const fields = componentStructure.value?.customFields
return Array.isArray(fields) ? fields : []
}
return []
})
const extractFormulaFields = (formula: string | null | undefined): string[] => {
if (!formula) return []
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
}
const errors = reactive<{ name?: string }>({}) const errors = reactive<{ name?: string }>({})
const nameInput = ref<HTMLInputElement | null>(null) const nameInput = ref<HTMLInputElement | null>(null)
@@ -290,11 +274,14 @@ const resetForm = () => {
errors.name = undefined errors.name = undefined
const incomingAny = incoming as Record<string, unknown>
form.referenceFormula = typeof incomingAny.referenceFormula === 'string' ? incomingAny.referenceFormula : null
resetStructures(incoming.structure, form.category) resetStructures(incoming.structure, form.category)
} }
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer')) const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value || isReadonly.value) const isSubmitDisabled = computed(() => saving.value || structureLoading.value || isReadonly.value)
const validate = () => { const validate = () => {
errors.name = undefined errors.name = undefined
@@ -328,20 +315,28 @@ const handleSubmit = () => {
} }
if (form.category === 'COMPONENT') { if (form.category === 'COMPONENT') {
const formula = form.referenceFormula || null
const requiredFields = extractFormulaFields(formula)
emit('submit', { emit('submit', {
...common, ...common,
category: 'COMPONENT', category: 'COMPONENT',
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)), structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
}) referenceFormula: formula,
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
} as ModelTypePayload)
return return
} }
if (form.category === 'PIECE') { if (form.category === 'PIECE') {
const formula = form.referenceFormula || null
const requiredFields = extractFormulaFields(formula)
emit('submit', { emit('submit', {
...common, ...common,
category: 'PIECE', category: 'PIECE',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)), structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
}) referenceFormula: formula,
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
} as ModelTypePayload)
return return
} }

View File

@@ -0,0 +1,115 @@
<template>
<section class="space-y-4">
<header>
<h3 class="text-lg font-semibold text-base-content">Génération de référence automatique</h3>
<p class="mt-1 text-sm text-base-content/70">
Cliquez sur un champ pour l'insérer dans la formule. Vous pouvez aussi taper du texte libre (séparateurs, préfixes…).
</p>
</header>
<div class="rounded-lg border border-base-300 p-4 space-y-4">
<div v-if="fieldNames.length" class="flex flex-wrap gap-2">
<button
v-for="name in fieldNames"
:key="name"
type="button"
class="btn btn-xs btn-outline btn-primary font-mono"
:disabled="disabled"
@click="insertField(name)"
>
{{ name }}
</button>
</div>
<p v-else class="text-sm text-base-content/50 italic">
Aucun champ personnalisé défini dans la structure.
</p>
<div>
<label class="label" for="reference-formula">
<span class="label-text">Formule</span>
</label>
<input
id="reference-formula"
ref="inputRef"
:value="modelValue"
type="text"
class="input input-bordered w-full font-mono"
placeholder="Ex: SNU {serie}-{diametre}/{type}"
:disabled="disabled"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value || null)"
/>
<p class="mt-1 text-xs text-base-content/60">
Laissez vide si ce type n'utilise pas de référence automatique.
</p>
</div>
<div v-if="modelValue" class="rounded bg-base-200 px-3 py-2 text-sm">
<span class="text-base-content/70">Aperçu :</span>
<span class="ml-1 font-mono font-semibold">{{ preview }}</span>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
interface CustomField {
name: string
type: string
}
const props = defineProps<{
modelValue: string | null | undefined
customFields: CustomField[]
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const inputRef = ref<HTMLInputElement | null>(null)
const fieldNames = computed(() =>
props.customFields.map(f => f.name).filter((n): n is string => Boolean(n)),
)
const previewExamples: Record<string, string> = {
text: 'VALEUR',
number: '123',
select: 'OPTION',
boolean: 'OUI',
date: '2026-01-01',
}
const preview = computed(() => {
if (!props.modelValue) return ''
const fieldMap = new Map<string, string>()
for (const f of props.customFields) {
if (f.name) {
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
}
}
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
})
const insertField = (fieldName: string) => {
const placeholder = `{${fieldName}}`
const input = inputRef.value
const current = props.modelValue ?? ''
if (!input) {
emit('update:modelValue', current + placeholder)
return
}
const start = input.selectionStart ?? current.length
const end = input.selectionEnd ?? start
const updated = current.slice(0, start) + placeholder + current.slice(end)
emit('update:modelValue', updated)
nextTick(() => {
const newPos = start + placeholder.length
input.focus()
input.setSelectionRange(newPos, newPos)
})
}
</script>

View File

@@ -0,0 +1,182 @@
<template>
<dialog class="modal" :class="{ 'modal-open': open }">
<div class="modal-box max-w-3xl">
<h3 class="text-lg font-bold text-base-content">
{{ modalTitle }}
</h3>
<p class="mt-1 text-sm text-base-content/70">
{{ modalSubtitle }}
</p>
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
<div v-if="loading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement des éléments liés
</div>
<div v-else-if="error" class="px-4 py-6 text-sm text-error">
{{ error }}
</div>
<div
v-else-if="items.length === 0"
class="px-4 py-6 text-sm text-base-content/60"
>
Aucun élément lié à cette catégorie.
</div>
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
<li
v-for="entry in items"
:key="entry.id"
class="px-2 py-1"
>
<button
type="button"
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@click="onOpenEdit(entry)"
>
<span class="font-medium text-base-content">{{ entry.name }}</span>
<span v-if="entry.reference" class="text-xs text-base-content/60">
Référence: {{ entry.reference }}
</span>
</button>
</li>
</ul>
</div>
<div class="modal-action">
<button type="button" class="btn" @click="emit('close')">
Fermer
</button>
</div>
</div>
</dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { humanizeError } from '~/shared/utils/errorMessages'
import type { ModelCategory, ModelType } from '~/services/modelTypes'
type RelatedEntry = {
id: string
name: string
reference?: string | null
}
const props = defineProps<{
open: boolean
modelType: ModelType | null
}>()
const emit = defineEmits<{
close: []
'open-edit': [entry: RelatedEntry]
}>()
const { get } = useApi()
const loading = ref(false)
const error = ref<string | null>(null)
const items = ref<RelatedEntry[]>([])
const categoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
COMPONENT: { plural: 'composants', singular: 'composant' },
PIECE: { plural: 'pièces', singular: 'pièce' },
PRODUCT: { plural: 'produits', singular: 'produit' },
}
const modalTitle = computed(() => {
if (!props.modelType) return 'Éléments liés'
return `Éléments liés à « ${props.modelType.name} »`
})
const modalSubtitle = computed(() => {
if (!props.modelType) return ''
const labels = categoryLabels[props.modelType.category] ?? categoryLabels.COMPONENT
const count = items.value.length
if (loading.value) return `Chargement des ${labels.plural}`
if (count === 0) return `Aucun ${labels.singular} lié.`
if (count === 1) return `1 ${labels.singular} lié.`
return `${count} ${labels.plural} liés.`
})
const resolveRelatedConfig = (category: ModelCategory) => {
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
return { endpoint: '/products', filterKey: 'typeProduct' }
}
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
if (!item || typeof item !== 'object') return null
const record = item as Record<string, unknown>
if (typeof record.id !== 'string') return null
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
const reference
= typeof record.reference === 'string' && record.reference.trim()
? record.reference
: typeof record.code === 'string' && record.code.trim()
? record.code
: null
return { id: record.id, name, reference }
}
const loadRelatedItems = async (modelType: ModelType) => {
const { endpoint, filterKey } = resolveRelatedConfig(modelType.category)
const params = new URLSearchParams()
params.set('itemsPerPage', '200')
params.set(filterKey, `/api/model_types/${modelType.id}`)
params.set('order[name]', 'asc')
loading.value = true
error.value = null
items.value = []
try {
const result = await get(`${endpoint}?${params.toString()}`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger les éléments liés.'
return
}
const collection = extractCollection(result.data)
items.value = collection
.map(mapRelatedEntry)
.filter((entry): entry is RelatedEntry => Boolean(entry))
}
catch (err) {
let raw: string | null = null
if (err && typeof err === 'object') {
const e = err as { data?: Record<string, unknown>, statusMessage?: string, message?: string }
if (e.data) {
const data = e.data
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
else if (typeof data.detail === 'string') raw = data.detail
else if (typeof data.message === 'string') raw = data.message
else if (typeof data.error === 'string') raw = data.error
}
if (!raw && typeof e.statusMessage === 'string') raw = e.statusMessage
if (!raw && typeof e.message === 'string') raw = e.message
}
error.value = humanizeError(raw)
}
finally {
loading.value = false
}
}
const onOpenEdit = (entry: RelatedEntry) => {
emit('open-edit', entry)
}
watch(
() => props.open,
(isOpen) => {
if (isOpen && props.modelType) {
void loadRelatedItems(props.modelType)
}
},
)
</script>

View File

@@ -1,27 +1,37 @@
<template> <template>
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow"> <div
class="card site-card shadow-md hover:shadow-xl transition-shadow overflow-hidden"
:style="{
borderTop: site.color ? `4px solid ${site.color}` : '4px solid transparent',
background: site.color ? `linear-gradient(160deg, ${site.color}30 0%, ${site.color}08 40%, var(--color-base-100) 100%)` : undefined,
}"
>
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="card-title text-lg"> <h3 class="card-title text-lg text-base-content">
{{ site.name }} {{ site.name }}
</h3> </h3>
<div class="badge badge-primary badge-sm"> <div
class="badge font-bold"
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
:class="!site.color ? 'badge-primary' : ''"
>
{{ machineCount }} machines {{ machineCount }} machines
</div> </div>
</div> </div>
<div class="space-y-3 text-sm"> <div class="space-y-3 text-sm">
<div class="flex items-center gap-2 text-gray-700"> <div class="flex items-center gap-2 text-base-content/80">
<IconLucideUser class="w-4 h-4 text-primary" aria-hidden="true" /> <IconLucideUser class="w-4 h-4 text-primary" aria-hidden="true" />
<span class="font-medium">{{ site.contactName }}</span> <span class="font-medium">{{ site.contactName }}</span>
</div> </div>
<div class="flex items-center gap-2 text-gray-600"> <div class="flex items-center gap-2 text-base-content/60">
<IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" /> <IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" />
<span>{{ formattedContactPhone }}</span> <span>{{ formattedContactPhone }}</span>
</div> </div>
<div class="flex items-start gap-2 text-gray-600"> <div class="flex items-start gap-2 text-base-content/60">
<IconLucideMapPin class="w-4 h-4 text-accent mt-1" aria-hidden="true" /> <IconLucideMapPin class="w-4 h-4 text-accent mt-1" aria-hidden="true" />
<span> <span>
{{ site.contactAddress }}<br> {{ site.contactAddress }}<br>
@@ -29,7 +39,7 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-2 text-gray-600"> <div class="flex items-center gap-2 text-base-content/60">
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" /> <IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
<span>{{ machineCount }} machine(s)</span> <span>{{ machineCount }} machine(s)</span>
</div> </div>

View File

@@ -17,6 +17,46 @@
/> />
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Couleur</span>
</label>
<div v-if="siteRef.color" class="flex items-center gap-3">
<input
:value="siteRef.color"
type="color"
class="w-10 h-10 rounded cursor-pointer border border-base-300"
:disabled="disabled"
@input="(e: Event) => { siteRef.color = (e.target as HTMLInputElement).value }"
>
<input
v-model="siteRef.color"
type="text"
placeholder="#000000"
class="input input-bordered input-sm flex-1"
:disabled="disabled"
maxlength="7"
>
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="disabled"
@click="siteRef.color = ''"
>
Effacer
</button>
</div>
<button
v-else
type="button"
class="btn btn-outline btn-sm w-fit"
:disabled="disabled"
@click="siteRef.color = '#3b82f6'"
>
Choisir une couleur
</button>
</div>
<SiteContactFormFields :form="siteRef" :disabled="disabled" /> <SiteContactFormFields :form="siteRef" :disabled="disabled" />
<div class="modal-action"> <div class="modal-action">
@@ -39,6 +79,7 @@ import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
type SiteForm = { type SiteForm = {
name: string name: string
color: string
contactName: string contactName: string
contactPhone: string contactPhone: string
contactAddress: string contactAddress: string

View File

@@ -3,7 +3,7 @@
<div class="modal-box max-w-md"> <div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-4"> <h3 class="font-bold text-lg mb-4">
{{ disabled ? 'Détails du site' : 'Modifier le site' }} {{ disabled ? 'Détails du site' : 'Modifier le site' }}
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span> <span v-if="siteName" class="block text-sm font-normal text-base-content/50">{{ siteName }}</span>
</h3> </h3>
<form class="space-y-4" @submit.prevent="emit('submit')"> <form class="space-y-4" @submit.prevent="emit('submit')">
<div class="form-control"> <div class="form-control">
@@ -20,6 +20,46 @@
> >
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Couleur</span>
</label>
<div v-if="form.color" class="flex items-center gap-3">
<input
:value="form.color"
type="color"
class="w-10 h-10 rounded cursor-pointer border border-base-300"
:disabled="disabled"
@input="form.color = $event.target.value"
>
<input
v-model="form.color"
type="text"
placeholder="#000000"
class="input input-bordered input-sm flex-1"
:disabled="disabled"
maxlength="7"
>
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="disabled"
@click="form.color = ''"
>
Effacer
</button>
</div>
<button
v-else
type="button"
class="btn btn-outline btn-sm w-fit"
:disabled="disabled"
@click="form.color = '#3b82f6'"
>
Choisir une couleur
</button>
</div>
<SiteContactFormFields :form="props.form" :disabled="disabled" /> <SiteContactFormFields :form="props.form" :disabled="disabled" />
<div class="border-t border-base-200 pt-4 space-y-4"> <div class="border-t border-base-200 pt-4 space-y-4">
@@ -28,7 +68,7 @@
<h4 class="font-semibold text-sm"> <h4 class="font-semibold text-sm">
Documents liés Documents liés
</h4> </h4>
<p class="text-xs text-gray-500"> <p class="text-xs text-base-content/50">
Ajoutez des documents (PDF, images...) relatifs à ce site. Ajoutez des documents (PDF, images...) relatifs à ce site.
</p> </p>
</div> </div>
@@ -74,7 +114,7 @@
<div class="font-medium"> <div class="font-medium">
{{ document.name }} {{ document.name }}
</div> </div>
<div class="text-xs text-gray-500"> <div class="text-xs text-base-content/50">
{{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }} {{ document.mimeType || 'Inconnu' }} {{ formatSize(document.size) }}
</div> </div>
</div> </div>

View File

@@ -1,114 +0,0 @@
import { computed, ref } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
type GuardLabels = {
singular: string
plural: string
verifying: string
}
type GuardConfig = {
endpoint: string
filterKey: string
labels: GuardLabels
}
const extractTotal = (payload: any, fallbackLength: number) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
}
if (Array.isArray(payload?.member)) {
return payload.member.length
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member'].length
}
return fallbackLength
}
export function useCategoryEditGuard (config: GuardConfig) {
const { get } = useApi()
const { showInfo } = useToast()
const linkedCount = ref(0)
const linkedLoading = ref(false)
const loadLinkedCount = async (modelTypeId: string) => {
linkedLoading.value = true
try {
const params = new URLSearchParams()
params.set('itemsPerPage', '1')
params.set(config.filterKey, `/api/model_types/${modelTypeId}`)
const result = await get(`${config.endpoint}?${params.toString()}`)
if (!result.success) {
linkedCount.value = 0
return
}
const fallbackLength = Array.isArray(result.data?.member)
? result.data.member.length
: Array.isArray(result.data?.['hydra:member'])
? result.data['hydra:member'].length
: 0
linkedCount.value = extractTotal(result.data, fallbackLength)
} catch (_error) {
linkedCount.value = 0
} finally {
linkedLoading.value = false
}
}
const isRestrictedMode = computed(
() => !linkedLoading.value && linkedCount.value > 0,
)
const isSubmitBlocked = computed(
() => linkedLoading.value,
)
const restrictedModeMessage = computed(() => {
if (linkedLoading.value) {
return config.labels.verifying
}
if (linkedCount.value <= 0) {
return ''
}
if (linkedCount.value === 1) {
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
}
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés et renommer les existants, mais pas modifier leur type ou les supprimer.`
})
const submitBlockMessage = computed(() => {
if (linkedLoading.value) {
return config.labels.verifying
}
return ''
})
const guardSubmitOrNotify = () => {
if (!isSubmitBlocked.value) {
return false
}
showInfo(submitBlockMessage.value || 'Veuillez patienter...')
return true
}
return {
linkedCount,
linkedLoading,
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
}
}

View File

@@ -3,6 +3,18 @@ import { useApi } from './useApi'
import { useToast } from './useToast' import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
export interface CommentDocument {
id: string
name: string
filename: string
mimeType: string
size: number
type: string
fileUrl: string
downloadUrl: string
createdAt: string
}
export interface Comment { export interface Comment {
id: string id: string
content: string content: string
@@ -17,6 +29,7 @@ export interface Comment {
resolvedAt?: string | null resolvedAt?: string | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
documents?: CommentDocument[]
} }
interface CommentResult { interface CommentResult {
@@ -33,7 +46,7 @@ interface CommentListResult {
} }
export function useComments() { export function useComments() {
const { get, post, patch, delete: del } = useApi() const { get, post, patch, postFormData, delete: del } = useApi()
const { showSuccess, showError } = useToast() const { showSuccess, showError } = useToast()
const loading = ref(false) const loading = ref(false)
@@ -44,16 +57,9 @@ export function useComments() {
): Promise<CommentListResult> => { ): Promise<CommentListResult> => {
loading.value = true loading.value = true
try { try {
const params = new URLSearchParams({ const result = await get<Comment[]>(`/comments/by-entity/${entityType}/${entityId}?status=${status}`)
entityType,
entityId,
status,
'order[createdAt]': 'desc',
itemsPerPage: '200',
})
const result = await get(`/comments?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection<Comment>(result.data) const items = (result.data ?? []) as Comment[]
return { success: true, data: items } return { success: true, data: items }
} }
return { success: false, error: result.error } return { success: false, error: result.error }
@@ -80,18 +86,15 @@ export function useComments() {
if (options.status) params.set('status', options.status) if (options.status) params.set('status', options.status)
if (options.entityType) params.set('entityType', options.entityType) if (options.entityType) params.set('entityType', options.entityType)
if (options.entityName) params.set('entityName', options.entityName) if (options.entityName) params.set('entityName', options.entityName)
const sortField = options.orderBy || 'createdAt' params.set('sort', options.orderBy || 'createdAt')
const sortDir = options.orderDir || 'desc' params.set('direction', options.orderDir || 'desc')
params.set(`order[${sortField}]`, sortDir)
params.set('itemsPerPage', String(options.itemsPerPage || 30)) params.set('itemsPerPage', String(options.itemsPerPage || 30))
params.set('page', String(options.page || 1)) params.set('page', String(options.page || 1))
const result = await get(`/comments?${params.toString()}`) const result = await get<{ items: Comment[]; total: number }>(`/comments/search/list?${params.toString()}`)
if (result.success) { if (result.success && result.data) {
const items = extractCollection<Comment>(result.data) const data = result.data as { items: Comment[]; total: number }
const raw = result.data as Record<string, unknown> | null return { success: true, data: data.items, total: data.total }
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
return { success: true, data: items, total }
} }
return { success: false, error: result.error } return { success: false, error: result.error }
} catch (error) { } catch (error) {
@@ -107,12 +110,26 @@ export function useComments() {
entityId: string, entityId: string,
content: string, content: string,
entityName?: string, entityName?: string,
files?: File[],
): Promise<CommentResult> => { ): Promise<CommentResult> => {
loading.value = true loading.value = true
try { try {
const payload: Record<string, string> = { entityType, entityId, content } let result
if (entityName) payload.entityName = entityName if (files && files.length > 0) {
const result = await post('/comments', payload) 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) { if (result.success) {
showSuccess('Commentaire ajouté') showSuccess('Commentaire ajouté')
return { success: true, data: result.data as Comment } return { success: true, data: result.data as Comment }

View File

@@ -0,0 +1,426 @@
/**
* Component creation page orchestration composable.
*
* Pure structure-assignment helpers live in
* `~/shared/utils/structureAssignmentHelpers.ts`.
*/
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import type { StructureAssignmentNode } from '~/components/ComponentStructureAssignmentNode.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import {
type CustomFieldInput,
normalizeCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import {
getStructurePieces,
resolvePieceLabel as _resolvePieceLabel,
resolveProductLabel as _resolveProductLabel,
resolveSubcomponentLabel,
fetchModelTypeNames,
buildTypeLabelMap,
} from '~/shared/utils/structureDisplayUtils'
import {
hasAssignments,
initializeStructureAssignments,
isAssignmentNodeComplete,
serializeStructureAssignments,
} from '~/shared/utils/structureAssignmentHelpers'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
interface ComponentCatalogType extends ModelType {
structure: ComponentModelStructure | null
customFields?: Array<Record<string, any>>
}
// ---------------------------------------------------------------------------
// Main composable
// ---------------------------------------------------------------------------
export function useComponentCreate() {
const route = useRoute()
const router = useRouter()
const { get } = useApi()
const { componentTypes, loadComponentTypes, loadingComponentTypes: loadingTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const {
createComposant,
composants: componentCatalogRef,
loading: componentsLoading,
} = useComposants()
const {
pieces: pieceCatalogRef,
loading: piecesLoading,
} = usePieces()
const {
products: productCatalogRef,
loading: productsLoading,
} = useProducts()
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { syncLinks } = useConstructeurLinks()
const { canEdit } = usePermissions()
// -------------------------------------------------------------------------
// Local state
// -------------------------------------------------------------------------
const selectedTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const submitting = ref(false)
const creationForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
})
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const lastSuggestedName = ref('')
const customFieldInputs = ref<CustomFieldInput[]>([])
const structureAssignments = ref<StructureAssignmentNode | null>(null)
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
// -------------------------------------------------------------------------
// Computed
// -------------------------------------------------------------------------
const availablePieces = computed(() => pieceCatalogRef.value ?? [])
const availableProducts = computed(() => productCatalogRef.value ?? [])
const availableComponents = computed(() => componentCatalogRef.value ?? [])
const structureDataLoading = computed(
() => !submitting.value && (piecesLoading.value || componentsLoading.value || productsLoading.value),
)
const fetchedPieceTypeMap = ref<Record<string, string>>({})
const pieceTypeLabelMap = computed(() =>
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
)
const productTypeLabelMap = computed(() =>
buildTypeLabelMap(productTypes.value),
)
const componentTypeLabelMap = computed(() =>
buildTypeLabelMap(componentTypes.value),
)
const componentTypeList = computed<ComponentCatalogType[]>(() =>
(componentTypes.value || [])
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
)
const typeOptionLabel = (type?: ComponentCatalogType) =>
type?.name || 'Catégorie'
const typeOptionDescription = (type?: ComponentCatalogType) =>
type?.description ? String(type.description) : ''
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
const structure = selectedType.value?.structure ?? null
return structure ? normalizeStructureForEditor(structure) : null
})
const structureHasRequirements = computed(() =>
hasAssignments(structureAssignments.value),
)
const structureSelectionsComplete = computed(() => {
if (!structureHasRequirements.value) {
return true
}
if (structureDataLoading.value) {
return false
}
if (!structureAssignments.value) {
return false
}
return isAssignmentNodeComplete(structureAssignments.value, true)
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() => Boolean(
canEdit.value
&& selectedType.value
&& creationForm.name
&& requiredCustomFieldsFilled.value
&& structureSelectionsComplete.value
&& !submitting.value,
))
const resolvePieceLabel = (piece: Record<string, any>) =>
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
const resolveProductLabel = (product: Record<string, any>) =>
_resolveProductLabel(product, productTypeLabelMap.value)
// -------------------------------------------------------------------------
// Watchers
// -------------------------------------------------------------------------
watch(
() => route.query.typeId,
(value) => {
if (typeof value === 'string') {
selectedTypeId.value = value
}
},
)
watch(selectedTypeId, (id) => {
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
if ((id || '') === current) {
return
}
const nextQuery = { ...route.query }
if (id) {
nextQuery.typeId = id
}
else {
delete nextQuery.typeId
}
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
})
const clearCreationForm = () => {
creationForm.name = ''
creationForm.description = ''
creationForm.reference = ''
creationForm.constructeurIds = []
creationForm.prix = ''
lastSuggestedName.value = ''
structureAssignments.value = null
}
watch(selectedType, (type) => {
if (!type) {
clearCreationForm()
customFieldInputs.value = []
structureAssignments.value = null
return
}
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
creationForm.name = type.name
}
lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(selectedTypeStructure.value)
structureAssignments.value = initializeStructureAssignments(selectedTypeStructure.value)
})
watch(
selectedTypeStructure,
(structure) => {
const ids = getStructurePieces(structure)
.map((piece: any) => piece?.typePieceId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (!ids.length) {
return
}
fetchModelTypeNames(Array.from(new Set(ids)), pieceTypeLabelMap.value, get)
.then((additions) => {
if (Object.keys(additions).length) {
fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions }
}
})
.catch(() => {})
},
{ immediate: true },
)
// -------------------------------------------------------------------------
// Submission
// -------------------------------------------------------------------------
const submitCreation = async () => {
if (!selectedType.value) {
toast.showError('Sélectionnez une catégorie de composant.')
return
}
const payload: Record<string, any> = {
name: creationForm.name.trim(),
typeComposantId: selectedType.value.id,
}
const description = creationForm.description.trim()
if (description) {
payload.description = description
}
const reference = creationForm.reference.trim()
if (reference) {
payload.reference = reference
}
// constructeurIds are handled via link entities, not in the main payload
const rawPrice = typeof creationForm.prix === 'string'
? creationForm.prix.trim()
: creationForm.prix === null || creationForm.prix === undefined
? ''
: String(creationForm.prix).trim()
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.prix = String(parsed)
}
}
const rootProductSelection
= structureAssignments.value?.products?.find(
(product) => typeof product.selectedProductId === 'string' && product.selectedProductId.trim().length > 0,
) ?? null
if (rootProductSelection?.selectedProductId) {
payload.productId = rootProductSelection.selectedProductId.trim()
}
if (structureHasRequirements.value && !structureSelectionsComplete.value) {
toast.showError('Complétez la sélection des pièces, produits et sous-composants.')
return
}
const serializedStructure = structureHasRequirements.value
? serializeStructureAssignments(structureAssignments.value)
: null
if (serializedStructure) {
payload.structure = serializedStructure
}
submitting.value = true
try {
const result = await createComposant(payload)
if (result.success) {
const createdComponent = result.data as Record<string, any>
await _saveCustomFieldValues(
'composant',
createdComponent.id,
[createdComponent?.typeComposant?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
if (selectedDocuments.value.length && result.data?.id) {
uploadingDocuments.value = true
const uploadResult = await uploadDocuments(
{
files: selectedDocuments.value,
context: { composantId: result.data.id },
},
{ updateStore: false },
)
if (!uploadResult.success) {
const message = uploadResult.error
? `Documents non ajoutés : ${uploadResult.error}`
: 'Documents non ajoutés : une erreur est survenue.'
toast.showError(message)
}
selectedDocuments.value = []
}
// Sync constructeur links after creation
if (constructeurLinks.value.length) {
await syncLinks('composant', createdComponent.id, [], constructeurLinks.value)
}
toast.showSuccess('Composant créé avec succès')
await router.replace(`/component/${createdComponent.id}?edit=true`)
}
else if (result.error) {
toast.showError(result.error)
}
}
catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de créer le composant')
}
finally {
submitting.value = false
uploadingDocuments.value = false
}
}
// -------------------------------------------------------------------------
// Initialization
// -------------------------------------------------------------------------
onMounted(async () => {
await Promise.allSettled([
loadComponentTypes(),
loadPieceTypes(),
loadProductTypes(),
])
})
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
return {
// State
selectedTypeId,
submitting,
creationForm,
constructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
structureAssignments,
selectedDocuments,
uploadingDocuments,
// Computed
loadingTypes,
componentTypeList,
selectedType,
selectedTypeStructure,
availablePieces,
availableProducts,
availableComponents,
piecesLoading,
productsLoading,
componentsLoading,
structureDataLoading,
pieceTypeLabelMap,
productTypeLabelMap,
componentTypeLabelMap,
structureHasRequirements,
structureSelectionsComplete,
canEdit,
canSubmit,
// Functions
typeOptionLabel,
typeOptionDescription,
formatStructurePreview,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
submitCreation,
}
}

View File

@@ -0,0 +1,597 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from '#imports'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useComposants } from '~/composables/useComposants'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useProductTypes } from '~/composables/useProductTypes'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useComponentHistory } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import {
getStructurePieces,
getStructureProducts,
resolvePieceLabel as _resolvePieceLabel,
resolveProductLabel as _resolveProductLabel,
resolveSubcomponentLabel,
fetchModelTypeNames,
buildTypeLabelMap,
} from '~/shared/utils/structureDisplayUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import { collectStructureSelections } from '~/shared/utils/structureSelectionUtils'
interface ComponentCatalogType extends ModelType {
structure: ComponentModelStructure | null
customFields?: Array<Record<string, any>>
}
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
structure: 'Structure',
typeComposant: 'Catégorie',
product: 'Produit lié',
constructeurIds: 'Fournisseurs',
}
export function useComponentEdit(componentId: string) {
const { canEdit } = usePermissions()
const router = useRouter()
const { get, patch } = useApi()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { productTypes, loadProductTypes } = useProductTypes()
const { updateComposant, composants: componentCatalogRef } = useComposants()
const { pieces } = usePieces()
const { products } = useProducts()
const { ensureConstructeurs } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useComponentHistory()
const component = ref<any | null>(null)
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const selectedTypeId = ref<string>('')
const editionForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
})
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const customFieldInputs = ref<CustomFieldInput[]>([])
const fetchedPieceTypeMap = ref<Record<string, string>>({})
const pieceTypeLabelMap = computed(() =>
buildTypeLabelMap(pieceTypes.value, fetchedPieceTypeMap.value),
)
const fetchedProductTypeMap = ref<Record<string, string>>({})
const productTypeLabelMap = computed(() =>
buildTypeLabelMap(productTypes.value, fetchedProductTypeMap.value),
)
const pieceCatalogMap = computed(() =>
new Map(
(pieces.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const productCatalogMap = computed(() =>
new Map(
(products.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const componentCatalogMap = computed(() =>
new Map(
(componentCatalogRef.value || [])
.filter((item: any) => item?.id)
.map((item: any) => [String(item.id), item]),
),
)
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteDocument(documentId, { updateStore: false })
if (result.success) {
componentDocuments.value = componentDocuments.value.filter((doc) => doc.id !== documentId)
}
}
const refreshDocuments = async () => {
if (!component.value?.id) {
componentDocuments.value = []
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByComponent(component.value.id, { updateStore: false })
if (result.success) {
componentDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
}
}
finally {
loadingDocuments.value = false
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !component.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadDocuments(
{
files,
context: { composantId: component.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
}
}
finally {
uploadingDocuments.value = false
}
}
const componentTypeList = computed<ComponentCatalogType[]>(() =>
(componentTypes.value || [])
.filter((item: any) => item?.category === 'COMPONENT') as ComponentCatalogType[],
)
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return componentTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
const structure = selectedType.value?.structure ?? null
return structure ? normalizeStructureForEditor(structure) : null
})
const refreshCustomFieldInputs = (
structureOverride?: ComponentModelStructure | null,
valuesOverride?: any[] | null,
) => {
const structure = structureOverride ?? selectedTypeStructure.value ?? null
const values = valuesOverride ?? component.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values)
}
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() => Boolean(
canEdit.value
&& component.value
&& editionForm.name
&& requiredCustomFieldsFilled.value
&& !saving.value,
))
const fetchComponent = async () => {
if (!componentId || typeof componentId !== 'string') {
component.value = null
componentDocuments.value = []
return
}
const result = await get(`/composants/${componentId}`)
if (result.success) {
component.value = result.data
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
loadHistory(result.data.id).catch(() => {})
}
else {
component.value = null
componentDocuments.value = []
}
}
const resolvePieceLabel = (piece: Record<string, any>) =>
_resolvePieceLabel(piece, pieceTypeLabelMap.value)
const resolveProductLabel = (product: Record<string, any>) =>
_resolveProductLabel(product, productTypeLabelMap.value)
const structureSelections = computed(() => {
const selections = collectStructureSelections(
component.value?.structure,
{
pieceCatalogMap: pieceCatalogMap.value,
productCatalogMap: productCatalogMap.value,
componentCatalogMap: componentCatalogMap.value,
},
{ resolvePieceLabel, resolveProductLabel, resolveSubcomponentLabel },
)
const total
= selections.pieces.length + selections.products.length + selections.components.length
return {
...selections,
total,
hasAny: total > 0,
}
})
// --- Slot local edits (saved on submit, not auto-saved) ---
const slotEdits = reactive<{
pieces: Record<string, { selectedPieceId?: string | null, quantity?: number }>
products: Record<string, { selectedProductId?: string | null }>
subcomponents: Record<string, { selectedComposantId?: string | null }>
}>({ pieces: {}, products: {}, subcomponents: {} })
const pieceSlotEntries = computed(() => {
const structure = component.value?.structure
if (!structure?.pieces) return []
return (structure.pieces as any[]).map((slot: any, i: number) => {
const edits = slotEdits.pieces[slot.slotId]
return {
slotId: slot.slotId,
typePieceId: slot.typePieceId,
selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null),
selectedPieceName: slot.selectedPieceName ?? null,
quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
position: slot.position ?? i,
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
}
})
})
const productSlotEntries = computed(() => {
const structure = component.value?.structure
if (!structure?.products) return []
return (structure.products as any[]).map((slot: any, i: number) => {
const edits = slotEdits.products[slot.slotId]
return {
slotId: slot.slotId,
typeProductId: slot.typeProductId,
selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null),
selectedProductName: slot.selectedProductName ?? null,
familyCode: slot.familyCode,
position: slot.position ?? i,
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
}
})
})
const subcomponentSlotEntries = computed(() => {
const structure = component.value?.structure
if (!structure?.subcomponents) return []
return (structure.subcomponents as any[]).map((slot: any, i: number) => {
const edits = slotEdits.subcomponents[slot.slotId]
return {
slotId: slot.slotId,
typeComposantId: slot.typeComposantId,
selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null),
selectedComponentName: slot.selectedComponentName ?? null,
alias: slot.alias,
familyCode: slot.familyCode,
position: slot.position ?? i,
label: slot.alias || `Sous-composant #${i + 1}`,
}
})
})
const setPieceSlotSelection = (slotId: string, selectedPieceId: string | null) => {
slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], selectedPieceId }
}
const setProductSlotSelection = (slotId: string, selectedProductId: string | null) => {
slotEdits.products[slotId] = { ...slotEdits.products[slotId], selectedProductId }
}
const setSubcomponentSlotSelection = (slotId: string, selectedComposantId: string | null) => {
slotEdits.subcomponents[slotId] = { ...slotEdits.subcomponents[slotId], selectedComposantId }
}
const setSlotQuantity = (slotId: string, quantity: number) => {
if (!slotId || quantity < 1) return
slotEdits.pieces[slotId] = { ...slotEdits.pieces[slotId], quantity: Math.max(1, quantity) }
}
const submitEdition = async () => {
if (!component.value) {
return
}
const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined
? ''
: String(editionForm.prix).trim()
const payload: Record<string, any> = {
name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
}
const reference = editionForm.reference.trim()
payload.reference = reference || null
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.prix = String(parsed)
}
}
else {
payload.prix = null
}
saving.value = true
try {
const result = await updateComposant(component.value.id, payload)
if (result.success && result.data) {
const updatedComponent = result.data as Record<string, any>
await _saveCustomFieldValues(
'composant',
updatedComponent.id,
[
updatedComponent?.typeComposant?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
// Save slot edits
const slotPromises: Promise<any>[] = []
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
if (Object.keys(edits).length) {
slotPromises.push(patch(`/composant-piece-slots/${slotId}`, {
...'selectedPieceId' in edits ? { selectedPieceId: edits.selectedPieceId } : {},
...'quantity' in edits ? { quantity: edits.quantity } : {},
}))
}
}
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
if ('selectedProductId' in edits) {
slotPromises.push(patch(`/composant-product-slots/${slotId}`, { selectedProductId: edits.selectedProductId }))
}
}
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
if ('selectedComposantId' in edits) {
slotPromises.push(patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId: edits.selectedComposantId }))
}
}
await Promise.all(slotPromises)
// Apply slot edits to local structure so UI reflects saved values
const structure = component.value?.structure
if (structure) {
for (const [slotId, edits] of Object.entries(slotEdits.pieces)) {
const slot = (structure.pieces as any[])?.find((s: any) => s.slotId === slotId)
if (slot) {
if ('selectedPieceId' in edits) slot.selectedPieceId = edits.selectedPieceId
if ('quantity' in edits) slot.quantity = edits.quantity
}
}
for (const [slotId, edits] of Object.entries(slotEdits.products)) {
const slot = (structure.products as any[])?.find((s: any) => s.slotId === slotId)
if (slot && 'selectedProductId' in edits) slot.selectedProductId = edits.selectedProductId
}
for (const [slotId, edits] of Object.entries(slotEdits.subcomponents)) {
const slot = (structure.subcomponents as any[])?.find((s: any) => s.slotId === slotId)
if (slot && 'selectedComposantId' in edits) slot.selectedComponentId = edits.selectedComposantId
}
}
// Reset local slot edits
slotEdits.pieces = {}
slotEdits.products = {}
slotEdits.subcomponents = {}
await syncLinks('composant', component.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
toast.showSuccess('Composant mis à jour avec succès.')
}
}
catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour du composant')
}
finally {
saving.value = false
}
}
// --- Watchers ---
const initialized = ref(false)
watch(
[component, selectedTypeStructure],
([currentComponent, currentStructure]) => {
if (!currentComponent) {
return
}
if (!initialized.value) {
const resolvedTypeId = currentComponent.typeComposantId
|| extractRelationId(currentComponent.typeComposant)
|| ''
if (resolvedTypeId && !currentComponent.typeComposantId) {
currentComponent.typeComposantId = resolvedTypeId
}
selectedTypeId.value = resolvedTypeId
editionForm.name = currentComponent.name || ''
editionForm.description = currentComponent.description || ''
editionForm.reference = currentComponent.reference || ''
// Load constructeur links
fetchLinks('composant', componentId).then((links) => {
constructeurLinks.value = links
originalConstructeurLinks.value = links.map(l => ({ ...l }))
editionForm.constructeurIds = constructeurIdsFromLinks(links)
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
})
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
initialized.value = true
}
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
},
{ immediate: true },
)
watch(
selectedTypeStructure,
(structure) => {
const pieceIds = getStructurePieces(structure)
.map((piece: any) => piece?.typePieceId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (pieceIds.length) {
fetchModelTypeNames(Array.from(new Set(pieceIds)), pieceTypeLabelMap.value, get)
.then((additions) => {
if (Object.keys(additions).length) {
fetchedPieceTypeMap.value = { ...fetchedPieceTypeMap.value, ...additions }
}
})
.catch(() => {})
}
const productIds = getStructureProducts(structure)
.map((product: any) => product?.typeProductId)
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
if (productIds.length) {
fetchModelTypeNames(Array.from(new Set(productIds)), productTypeLabelMap.value, get)
.then((additions) => {
if (Object.keys(additions).length) {
fetchedProductTypeMap.value = { ...fetchedProductTypeMap.value, ...additions }
}
})
.catch(() => {})
}
},
{ immediate: true },
)
// --- Lifecycle ---
onMounted(async () => {
await Promise.allSettled([
loadComponentTypes(),
loadPieceTypes(),
loadProductTypes(),
fetchComponent(),
])
loading.value = false
})
return {
// State
component,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
componentDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
constructeurLinks,
originalConstructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
historyFieldLabels,
// Computed
canEdit,
canSubmit,
componentTypeList,
selectedType,
selectedTypeStructure,
structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
// History
history,
historyLoading,
historyError,
// Methods
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
refreshDocuments,
submitEdition,
fetchComponent,
setSlotQuantity,
setPieceSlotSelection,
setProductSlotSelection,
setSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
}
}

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
@@ -42,6 +42,7 @@ interface LoadComposantsOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
typeName?: string typeName?: string
typeComposantId?: string
force?: boolean force?: boolean
} }
@@ -109,17 +110,18 @@ export function useComposants() {
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
typeName, typeName,
typeComposantId,
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && !typeName && page === 1) { if (!force && loaded.value && !search && !typeName && !typeComposantId && page === 1) {
return { return {
success: true, success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage }, data: { items: composants.value, total: total.value, page, itemsPerPage },
} }
} }
if (loading.value) { if (!typeComposantId && loading.value) {
return { return {
success: true, success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage }, data: { items: composants.value, total: total.value, page, itemsPerPage },
@@ -128,33 +130,41 @@ export function useComposants() {
loading.value = true loading.value = true
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage)) params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page)) params.set('page', String(page))
if (search && search.trim()) { if (search && search.trim()) {
params.set('name', search.trim()) params.set('search', search.trim())
} }
if (typeName && typeName.trim()) { if (typeName && typeName.trim()) {
params.set('typeComposant.name', typeName.trim()) params.set('typeComposant.name', typeName.trim())
} }
if (typeComposantId) {
params.set('typeComposant', typeComposantId)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/composants?${params.toString()}`) const result = await get(`/composants?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
composants.value = enrichedItems const resultTotal = extractTotal(result.data, items.length)
total.value = extractTotal(result.data, items.length)
loaded.value = true if (!typeComposantId) {
composants.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return { return {
success: true, success: true,
data: { data: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: resultTotal,
page, page,
itemsPerPage, itemsPerPage,
}, },
@@ -172,7 +182,8 @@ export function useComposants() {
const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => { const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
const result = await post('/composants', normalizedPayload) const result = await post('/composants', normalizedPayload)
if (result.success && result.data) { if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data as Composant) const enriched = await withResolvedConstructeurs(result.data as Composant)
@@ -199,7 +210,8 @@ export function useComposants() {
const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => { const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = composantData as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
const result = await patch(`/composants/${id}`, normalizedPayload) const result = await patch(`/composants/${id}`, normalizedPayload)
if (result.success && result.data) { if (result.success && result.data) {
const updated = await withResolvedConstructeurs(result.data as Composant) const updated = await withResolvedConstructeurs(result.data as Composant)

View File

@@ -0,0 +1,103 @@
import { useApi } from '~/composables/useApi'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { extractCollection } from '~/shared/utils/apiHelpers'
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_KEYS: Record<EntityType, string> = {
machine: 'machine',
piece: 'piece',
composant: 'composant',
product: 'product',
}
const ENTITY_PLURALS: Record<EntityType, string> = {
machine: 'machines',
piece: 'pieces',
composant: 'composants',
product: 'products',
}
export function useConstructeurLinks() {
const { get, post, patch, delete: del } = useApi()
const fetchLinks = async (
entityType: EntityType,
entityId: string,
): Promise<ConstructeurLinkEntry[]> => {
const endpoint = ENDPOINTS[entityType]
const key = ENTITY_KEYS[entityType]
const plural = ENTITY_PLURALS[entityType]
const url = `${endpoint}?${key}=/api/${plural}/${entityId}`
const result = await get(url)
if (!result.success || !result.data) return []
const members = extractCollection(result.data)
if (!Array.isArray(members)) return []
return members.map((link: any) => ({
linkId: link.id ?? (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : undefined),
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 key = ENTITY_KEYS[entityType]
const plural = ENTITY_PLURALS[entityType]
const entityIri = `/api/${plural}/${entityId}`
const originalMap = new Map(originalLinks.map(l => [l.constructeurId, l]))
const formMap = new Map(formLinks.map(l => [l.constructeurId, l]))
const promises: Promise<any>[] = []
// Delete removed links
for (const [cId, orig] of originalMap) {
if (!formMap.has(cId) && orig.linkId) {
promises.push(del(`${endpoint}/${orig.linkId}`))
}
}
// Create new links
for (const [cId, form] of formMap) {
if (!originalMap.has(cId)) {
promises.push(post(endpoint, {
[key]: 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 ?? null) !== (form.supplierReference ?? null)) {
promises.push(patch(`${endpoint}/${orig.linkId}`, {
supplierReference: form.supplierReference || null,
}))
}
}
await Promise.allSettled(promises)
}
return { fetchLinks, syncLinks }
}

View File

@@ -0,0 +1,26 @@
const isDark = ref(false)
export function useDarkMode() {
const toggle = () => {
isDark.value = !isDark.value
applyTheme()
}
const applyTheme = () => {
const theme = isDark.value ? 'mytheme-dark' : 'mytheme'
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('theme', theme)
}
const init = () => {
const saved = localStorage.getItem('theme')
if (saved === 'mytheme-dark') {
isDark.value = true
} else if (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches) {
isDark.value = true
}
applyTheme()
}
return { isDark, toggle, init }
}

View File

@@ -1,4 +1,4 @@
import { ref, computed, type Ref, type ComputedRef } from 'vue' import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'
import { useUrlState } from './useUrlState' import { useUrlState } from './useUrlState'
import type { DataTableSort, DataTablePagination, DataTableColumnFilters, SortDirection } from '~/shared/types/dataTable' import type { DataTableSort, DataTablePagination, DataTableColumnFilters, SortDirection } from '~/shared/types/dataTable'
@@ -22,6 +22,8 @@ export interface UseDataTableOptions {
persistToUrl?: boolean persistToUrl?: boolean
/** Extra URL state params for page-specific filters */ /** Extra URL state params for page-specific filters */
extraParams?: Record<string, { default: string | number; type?: 'string' | 'number' }> extraParams?: Record<string, { default: string | number; type?: 'string' | 'number' }>
/** Column filter keys to persist in URL (prefixed with `f.` in query string) */
columnFilterKeys?: string[]
} }
export interface UseDataTableReturn { export interface UseDataTableReturn {
@@ -56,6 +58,7 @@ export function useDataTable(
searchDebounceMs = 300, searchDebounceMs = 300,
persistToUrl = true, persistToUrl = true,
extraParams = {}, extraParams = {},
columnFilterKeys = [],
} = options } = options
let searchTerm: Ref<string> let searchTerm: Ref<string>
@@ -64,6 +67,7 @@ export function useDataTable(
let currentPage: Ref<number> let currentPage: Ref<number>
let itemsPerPage: Ref<number> let itemsPerPage: Ref<number>
const filters: Record<string, Ref<string | number>> = {} const filters: Record<string, Ref<string | number>> = {}
const columnFilterRefs: Record<string, Ref<string>> = {}
if (persistToUrl) { if (persistToUrl) {
const paramDefs: Record<string, { default: string | number; type?: 'string' | 'number'; debounce?: number }> = { const paramDefs: Record<string, { default: string | number; type?: 'string' | 'number'; debounce?: number }> = {
@@ -75,6 +79,10 @@ export function useDataTable(
...extraParams, ...extraParams,
} }
for (const key of columnFilterKeys) {
paramDefs[`f.${key}`] = { default: '', debounce: 300 }
}
const state = useUrlState(paramDefs, { const state = useUrlState(paramDefs, {
onRestore: () => deps.fetchData(), onRestore: () => deps.fetchData(),
}) })
@@ -88,6 +96,10 @@ export function useDataTable(
for (const key of Object.keys(extraParams)) { for (const key of Object.keys(extraParams)) {
filters[key] = (state as Record<string, Ref<string | number>>)[key]! filters[key] = (state as Record<string, Ref<string | number>>)[key]!
} }
for (const key of columnFilterKeys) {
columnFilterRefs[key] = (state as Record<string, Ref<string>>)[`f.${key}`]!
}
} }
else { else {
searchTerm = ref('') searchTerm = ref('')
@@ -137,8 +149,31 @@ export function useDataTable(
deps.fetchData() deps.fetchData()
} }
// Column filters // Column filters — seed from URL-persisted refs
const columnFilters = ref<DataTableColumnFilters>({}) const initialColumnFilters: DataTableColumnFilters = {}
for (const [key, r] of Object.entries(columnFilterRefs)) {
if (r.value) initialColumnFilters[key] = r.value
}
const columnFilters = ref<DataTableColumnFilters>(initialColumnFilters)
// Sync columnFilters → URL refs
if (persistToUrl && columnFilterKeys.length > 0) {
watch(columnFilters, (val) => {
for (const key of columnFilterKeys) {
columnFilterRefs[key]!.value = val[key] || ''
}
}, { deep: true })
// Sync URL refs → columnFilters (back/forward navigation)
for (const key of columnFilterKeys) {
watch(columnFilterRefs[key]!, (urlVal) => {
const current = columnFilters.value[key] || ''
if (current !== urlVal) {
columnFilters.value = { ...columnFilters.value, [key]: urlVal }
}
})
}
}
const handleColumnFiltersChange = (newFilters: DataTableColumnFilters) => { const handleColumnFiltersChange = (newFilters: DataTableColumnFilters) => {
columnFilters.value = newFilters columnFilters.value = newFilters

View File

@@ -11,6 +11,7 @@ export interface Document {
size: number size: number
fileUrl: string fileUrl: string
downloadUrl: string downloadUrl: string
type?: string
/** @deprecated Legacy Base64 data URI — use fileUrl instead */ /** @deprecated Legacy Base64 data URI — use fileUrl instead */
path?: string path?: string
createdAt?: string createdAt?: string
@@ -32,6 +33,7 @@ export interface UploadContext {
composantId?: string composantId?: string
productId?: string productId?: string
pieceId?: string pieceId?: string
type?: string
} }
export interface DocumentResult { export interface DocumentResult {
@@ -47,6 +49,7 @@ interface LoadDocumentsOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
attachmentFilter?: string attachmentFilter?: string
type?: string
force?: boolean force?: boolean
} }
@@ -63,7 +66,7 @@ const extractTotal = (payload: unknown, fallbackLength: number): number => {
} }
export function useDocuments() { export function useDocuments() {
const { get, postFormData, delete: del } = useApi() const { get, patch, postFormData, delete: del } = useApi()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const loadFromEndpoint = async ( const loadFromEndpoint = async (
@@ -103,10 +106,11 @@ export function useDocuments() {
orderBy = 'createdAt', orderBy = 'createdAt',
orderDir = 'desc', orderDir = 'desc',
attachmentFilter = 'all', attachmentFilter = 'all',
type = 'all',
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all') { if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all' && type === 'all') {
return { success: true, data: documents.value } return { success: true, data: documents.value }
} }
@@ -128,6 +132,10 @@ export function useDocuments() {
params.set(`exists[${attachmentFilter}]`, 'true') params.set(`exists[${attachmentFilter}]`, 'true')
} }
if (type && type !== 'all') {
params.set('type', type)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/documents?${params.toString()}`) const result = await get(`/documents?${params.toString()}`)
@@ -218,6 +226,7 @@ export function useDocuments() {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
formData.append('name', file.name) formData.append('name', file.name)
if (context.type) formData.append('type', context.type)
if (context.siteId) formData.append('siteId', context.siteId) if (context.siteId) formData.append('siteId', context.siteId)
if (context.machineId) formData.append('machineId', context.machineId) if (context.machineId) formData.append('machineId', context.machineId)
@@ -280,6 +289,33 @@ export function useDocuments() {
} }
} }
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
}
}
return { return {
documents, documents,
total, total,
@@ -292,6 +328,7 @@ export function useDocuments() {
loadDocumentsByPiece, loadDocumentsByPiece,
loadDocumentsByProduct, loadDocumentsByProduct,
uploadDocuments, uploadDocuments,
updateDocument,
deleteDocument, deleteDocument,
} }
} }

View File

@@ -0,0 +1,109 @@
import { ref } from 'vue'
interface DragReorderHandlers {
draggingIndex: Ref<number | null>
dropTargetIndex: Ref<number | null>
onDragStart: (index: number, event: DragEvent) => void
onDragEnter: (index: number) => void
onDragOver: (event: DragEvent) => void
onDrop: (index: number) => void
onDragEnd: () => void
reorderClass: (index: number) => string
reset: () => void
}
interface DragReorderOptions {
draggingClass?: string
dropTargetClass?: string
onReorder?: () => void
}
function moveItemInPlace<T>(list: T[], from: number, to: number): void {
if (from === to) return
if (from < 0 || to < 0 || from >= list.length || to >= list.length) return
const updated = list.slice()
const [item] = updated.splice(from, 1)
if (item === undefined) return
updated.splice(to, 0, item)
list.splice(0, list.length, ...updated)
}
export function useDragReorder(
getList: () => unknown[] | undefined,
options: DragReorderOptions = {},
): DragReorderHandlers {
const {
draggingClass = 'border-dashed border-primary',
dropTargetClass = 'border-primary border-dashed bg-primary/5',
onReorder,
} = options
const draggingIndex = ref<number | null>(null)
const dropTargetIndex = ref<number | null>(null)
const reset = () => {
draggingIndex.value = null
dropTargetIndex.value = null
}
const onDragStart = (index: number, event: DragEvent) => {
draggingIndex.value = index
dropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (draggingIndex.value === null) return
dropTargetIndex.value = index
}
const onDragOver = (event: DragEvent) => {
event.preventDefault()
}
const onDrop = (index: number) => {
const list = getList()
if (!Array.isArray(list)) {
reset()
return
}
const from = draggingIndex.value
if (from === null) {
reset()
return
}
moveItemInPlace(list, from, index)
onReorder?.()
reset()
}
const onDragEnd = () => {
reset()
}
const reorderClass = (index: number): string => {
if (draggingIndex.value === index) return draggingClass
if (
draggingIndex.value !== null
&& dropTargetIndex.value === index
&& draggingIndex.value !== index
) {
return dropTargetClass
}
return ''
}
return {
draggingIndex,
dropTargetIndex,
onDragStart,
onDragEnter,
onDragOver,
onDrop,
onDragEnd,
reorderClass,
reset,
}
}

View File

@@ -17,7 +17,7 @@ export interface EntityDocumentsDeps {
export function useEntityDocuments(deps: EntityDocumentsDeps) { export function useEntityDocuments(deps: EntityDocumentsDeps) {
const { entity, entityType } = deps const { entity, entityType } = deps
const { uploadDocuments, deleteDocument } = useDocuments() const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
const loadDocumentsFn = entityType === 'composant' const loadDocumentsFn = entityType === 'composant'
? useDocuments().loadDocumentsByComponent ? useDocuments().loadDocumentsByComponent
@@ -56,7 +56,7 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
// CRUD operations // CRUD operations
const refreshDocuments = async () => { const refreshDocuments = async () => {
const e = entity() const e = entity()
if (!e?.id) return if (!e?.id || e._structurePiece) return
loadingDocuments.value = true loadingDocuments.value = true
try { try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false }) const result: any = await loadDocumentsFn(e.id, { updateStore: false })
@@ -104,6 +104,19 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
} }
} }
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
}
return { return {
documents, documents,
selectedFiles, selectedFiles,
@@ -118,5 +131,6 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
ensureDocumentsLoaded, ensureDocumentsLoaded,
handleFilesAdded, handleFilesAdded,
removeDocument, removeDocument,
editDocument,
} }
} }

View File

@@ -23,6 +23,7 @@ export type EntityHistoryEntry = {
} }
const ENTITY_ENDPOINTS: Record<string, string> = { const ENTITY_ENDPOINTS: Record<string, string> = {
machine: '/machines',
composant: '/composants', composant: '/composants',
piece: '/pieces', piece: '/pieces',
product: '/products', product: '/products',
@@ -35,7 +36,7 @@ const extractItems = (payload: any): EntityHistoryEntry[] => {
return [] return []
} }
export function useEntityHistory(entityType: 'composant' | 'piece' | 'product') { export function useEntityHistory(entityType: 'machine' | 'composant' | 'piece' | 'product') {
const { get } = useApi() const { get } = useApi()
const basePath = ENTITY_ENDPOINTS[entityType] const basePath = ENTITY_ENDPOINTS[entityType]

View File

@@ -127,8 +127,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
showSuccess(`Type de ${label} "${data.name}" créé`) showSuccess(`Type de ${label} "${data.name}" créé`)
return { success: true, data: normalized } return { success: true, data: normalized }
} catch (error) { } catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string } const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.message || err?.message const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw) const message = humanizeError(raw)
showError(`Impossible de créer le type de ${label} : ${message}`) showError(`Impossible de créer le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }
@@ -153,8 +153,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
showSuccess(`Type de ${label} "${data.name}" mis à jour`) showSuccess(`Type de ${label} "${data.name}" mis à jour`)
return { success: true, data: normalized } return { success: true, data: normalized }
} catch (error) { } catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string } const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.message || err?.message const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw) const message = humanizeError(raw)
showError(`Impossible de mettre à jour le type de ${label} : ${message}`) showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }
@@ -171,8 +171,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
showSuccess(`Type de ${label} supprimé`) showSuccess(`Type de ${label} supprimé`)
return { success: true } return { success: true }
} catch (error) { } catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string } const err = error as Error & { data?: { error?: string; message?: string }; message?: string }
const raw = err?.data?.message || err?.message const raw = err?.data?.error || err?.data?.message || err?.message
const message = humanizeError(raw) const message = humanizeError(raw)
showError(`Impossible de supprimer le type de ${label} : ${message}`) showError(`Impossible de supprimer le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }

View File

@@ -0,0 +1,98 @@
import { ref, toValue } from 'vue'
import { useApi } from '~/composables/useApi'
import type { MaybeRef } from 'vue'
export interface VersionEntry {
version: number
action: 'create' | 'update' | 'restore' | string
createdAt: string
actor: { id: string; label: string } | null
diff: Record<string, { from: unknown; to: unknown }> | null
}
export interface RestorePreview {
version: number
restoreMode: 'full' | 'partial'
diff: Record<string, { current: unknown; restored: unknown }>
warnings: Array<{
field: string
message: string
missingEntityId: string | null
missingEntityName: string | null
}>
snapshot: Record<string, unknown>
}
export interface RestoreResult {
success: boolean
newVersion: number
restoredFromVersion: number
restoreMode: 'full' | 'partial'
warnings: RestorePreview['warnings']
}
const ENTITY_ENDPOINTS: Record<string, string> = {
machine: '/machines',
composant: '/composants',
piece: '/pieces',
product: '/products',
}
interface Deps {
entityType: MaybeRef<string>
entityId: MaybeRef<string>
}
export function useEntityVersions(deps: Deps) {
const { get, post } = useApi()
const versions = ref<VersionEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const getPath = () => {
const type = toValue(deps.entityType)
const id = toValue(deps.entityId)
const base = ENTITY_ENDPOINTS[type]
return `${base}/${id}`
}
const fetchVersions = async () => {
loading.value = true
error.value = null
try {
const result = await get(`${getPath()}/versions`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger les versions.'
versions.value = []
return
}
versions.value = result.data?.items ?? []
}
catch (err: any) {
error.value = err?.message ?? 'Erreur inconnue'
versions.value = []
}
finally {
loading.value = false
}
}
const fetchPreview = async (version: number): Promise<RestorePreview | null> => {
const result = await get<RestorePreview>(`${getPath()}/versions/${version}/preview`)
if (!result.success || !result.data) {
return null
}
return result.data
}
const restore = async (version: number): Promise<RestoreResult | null> => {
const result = await post<RestoreResult>(`${getPath()}/versions/${version}/restore`, {})
if (!result.success || !result.data) {
return null
}
return result.data
}
return { versions, loading, error, fetchVersions, fetchPreview, restore }
}

View File

@@ -0,0 +1,327 @@
import { reactive, ref } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
// --- Types ---
export type MachineFieldType = 'text' | 'number' | 'select' | 'boolean' | 'date'
export interface MachineCustomFieldEditorField {
uid: string
serverId?: string
name: string
type: MachineFieldType
required: boolean
optionsText: string
orderIndex: number
}
interface InitialDef {
id: string
name: string
type: string
required: boolean
options?: string[]
orderIndex: number
defaultValue?: unknown
}
interface Deps {
machineId: string
initialDefs: InitialDef[]
onSaved: () => void | Promise<void>
}
// --- Helpers ---
let uidCounter = 0
const createUid = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
uidCounter += 1
return `mcf-${Date.now().toString(36)}-${uidCounter}`
}
const normalizeLineEndings = (value: string): string =>
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const toEditorField = (def: InitialDef, index: number): MachineCustomFieldEditorField => ({
uid: createUid(),
serverId: def.id,
name: def.name || '',
type: (def.type || 'text') as MachineFieldType,
required: Boolean(def.required),
optionsText: normalizeLineEndings(
Array.isArray(def.options) ? def.options.join('\n') : '',
),
orderIndex: typeof def.orderIndex === 'number' ? def.orderIndex : index,
})
const hydrateFields = (defs: InitialDef[]): MachineCustomFieldEditorField[] =>
defs
.map((def, index) => toEditorField(def, index))
.sort((a, b) => a.orderIndex - b.orderIndex)
.map((field, index) => ({ ...field, orderIndex: index }))
const buildSnapshot = (defs: InitialDef[]): Map<string, InitialDef> => {
const map = new Map<string, InitialDef>()
for (const def of defs) {
map.set(def.id, def)
}
return map
}
const applyOrderIndex = (
list: MachineCustomFieldEditorField[],
): MachineCustomFieldEditorField[] =>
list.map((field, index) => ({ ...field, orderIndex: index }))
const parseOptions = (optionsText: string): string[] =>
normalizeLineEndings(optionsText)
.split('\n')
.map(o => o.trim())
.filter(o => o.length > 0)
// --- Composable ---
export function useMachineCustomFieldDefs(deps: Deps) {
const { apiCall } = useApi()
const { showSuccess, showError } = useToast()
// --- State ---
const fields = ref<MachineCustomFieldEditorField[]>(hydrateFields(deps.initialDefs))
const initialSnapshot = ref<Map<string, InitialDef>>(buildSnapshot(deps.initialDefs))
const saving = ref(false)
// --- CRUD ---
const addField = () => {
const next = fields.value.slice()
next.push({
uid: createUid(),
name: '',
type: 'text',
required: false,
optionsText: '',
orderIndex: next.length,
})
fields.value = applyOrderIndex(next)
}
const removeField = (index: number) => {
const next = fields.value.filter((_, i) => i !== index)
fields.value = applyOrderIndex(next)
}
// --- Drag & drop ---
const dragState = reactive({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const resetDragState = () => {
dragState.draggingIndex = null
dragState.dropTargetIndex = null
}
const onDragStart = (index: number, event: DragEvent) => {
dragState.draggingIndex = index
dragState.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (dragState.draggingIndex === null) return
dragState.dropTargetIndex = index
}
const onDrop = (index: number) => {
const from = dragState.draggingIndex
if (from === null) {
resetDragState()
return
}
if (from === index) {
resetDragState()
return
}
const list = fields.value.slice()
if (from < 0 || index < 0 || from >= list.length || index >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
if (!moved) {
resetDragState()
return
}
list.splice(index, 0, moved)
fields.value = applyOrderIndex(list)
resetDragState()
}
const onDragEnd = () => {
resetDragState()
}
const reorderClass = (index: number): string => {
if (dragState.draggingIndex === index) {
return 'border-dashed border-primary bg-primary/5'
}
if (
dragState.draggingIndex !== null
&& dragState.dropTargetIndex === index
&& dragState.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/10'
}
return ''
}
// --- Save ---
const saveDefinitions = async () => {
if (saving.value) return
// Validate: remove empty-name fields before saving
const emptyNameFields = fields.value.filter(f => !f.name.trim() && !f.serverId)
if (emptyNameFields.length > 0) {
fields.value = applyOrderIndex(fields.value.filter(f => f.name.trim() || f.serverId))
}
saving.value = true
try {
const snapshot = initialSnapshot.value
const currentServerIds = new Set(
fields.value.filter(f => f.serverId).map(f => f.serverId!),
)
// DELETE removed fields
const deletedIds = [...snapshot.keys()].filter(id => !currentServerIds.has(id))
for (const id of deletedIds) {
const result = await apiCall(`/custom_fields/${id}`, { method: 'DELETE' })
if (!result.success) {
showError('Erreur lors de la suppression d\'un champ personnalisé')
await deps.onSaved()
return
}
}
let hasNewFields = false
for (const field of fields.value) {
const name = field.name.trim()
if (!name) continue
const options = field.type === 'select' ? parseOptions(field.optionsText) : []
if (!field.serverId) {
// POST new field
hasNewFields = true
const body: Record<string, unknown> = {
name,
type: field.type,
required: field.required,
options,
orderIndex: field.orderIndex,
machine: `/api/machines/${deps.machineId}`,
}
const result = await apiCall('/custom_fields', {
method: 'POST',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(body),
})
if (!result.success) {
showError('Erreur lors de la création d\'un champ personnalisé')
await deps.onSaved()
return
}
} else {
// PATCH modified field
const original = snapshot.get(field.serverId)
const originalOptions = Array.isArray(original?.options)
? original.options.join('\n')
: ''
const currentOptions = field.type === 'select' ? field.optionsText : ''
const changed
= original?.name !== name
|| original?.type !== field.type
|| original?.required !== field.required
|| normalizeLineEndings(originalOptions) !== normalizeLineEndings(currentOptions)
|| original?.orderIndex !== field.orderIndex
if (changed) {
const body: Record<string, unknown> = {
name,
type: field.type,
required: field.required,
options,
orderIndex: field.orderIndex,
}
const result = await apiCall(`/custom_fields/${field.serverId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify(body),
})
if (!result.success) {
showError('Erreur lors de la mise à jour d\'un champ personnalisé')
await deps.onSaved()
return
}
}
}
}
// Initialize missing custom field values if new fields were created
if (hasNewFields) {
await apiCall(`/machines/${deps.machineId}/add-custom-fields`, {
method: 'POST',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify({}),
})
}
showSuccess('Champs personnalisés sauvegardés avec succès')
await deps.onSaved()
} catch {
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')
await deps.onSaved()
} finally {
saving.value = false
}
}
// --- Reinit ---
const reinit = (newDefs: InitialDef[]) => {
fields.value = hydrateFields(newDefs)
initialSnapshot.value = buildSnapshot(newDefs)
}
return {
fields,
saving,
dragState,
addField,
removeField,
onDragStart,
onDragEnter,
onDrop,
onDragEnd,
reorderClass,
saveDefinitions,
reinit,
}
}

View File

@@ -0,0 +1,449 @@
/**
* Machine detail — custom field management sub-composable.
*
* Handles custom field resolution, display filtering, sync and updates
* for machines, components and pieces.
*/
import { ref, computed } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import { normalizeStructureForEditor } from '~/shared/modelUtils'
import {
shouldDisplayCustomField,
normalizeExistingCustomFieldDefinitions,
normalizeCustomFieldValueEntry,
mergeCustomFieldValuesWithDefinitions,
dedupeCustomFieldEntries,
} from '~/shared/utils/customFieldUtils'
import {
resolveConstructeurs,
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
type AnyRecord = Record<string, unknown>
interface MachineDetailCustomFieldsDeps {
machine: Ref<AnyRecord | null>
isEditMode: Ref<boolean>
constructeurs: Ref<unknown[]>
resolveProductReference: (source: AnyRecord) => { product: unknown; productId: string | null }
getProductDisplay: (source: AnyRecord) => unknown
}
export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps) {
const { machine, isEditMode, constructeurs, resolveProductReference, getProductDisplay } = deps
const {
upsertCustomFieldValue,
updateCustomFieldValue: updateCustomFieldValueApi,
} = useCustomFields()
const toast = useToast()
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const machineCustomFields = ref<AnyRecord[]>([])
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const visibleMachineCustomFields = computed(() => {
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
if (isEditMode.value) return fields
return fields.filter((field) => shouldDisplayCustomField(field))
})
// ---------------------------------------------------------------------------
// Transform helpers
// ---------------------------------------------------------------------------
const getStructureCustomFields = (structure: unknown): AnyRecord[] => {
if (!structure || typeof structure !== 'object') return []
const normalized = normalizeStructureForEditor(structure as any) as any
return Array.isArray(normalized?.customFields)
? (normalized.customFields as AnyRecord[])
: []
}
const transformCustomFields = (piecesData: AnyRecord[]): AnyRecord[] => {
return (piecesData || []).map((piece) => {
const typePiece = (piece.typePiece as AnyRecord) || {}
const normalizeStructureDefs = (structure: unknown) =>
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
const normalizedStructureDefs = [
normalizeStructureDefs((piece.definition as AnyRecord)?.structure),
normalizeStructureDefs(typePiece.structure),
]
const valueEntries = [
...(Array.isArray(piece.customFieldValues) ? piece.customFieldValues : []),
...(Array.isArray(piece.customFields)
? (piece.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
...(Array.isArray(typePiece.customFieldValues)
? (typePiece.customFieldValues as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const customFields = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(piece.customFields),
normalizeExistingCustomFieldDefinitions((piece.definition as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions((piece.typePiece as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(typePiece.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
),
)
const constructeurIds = uniqueConstructeurIds(
piece.constructeurs,
piece.constructeurIds,
piece.constructeurId,
piece.constructeur,
(piece.originalPiece as AnyRecord)?.constructeurs,
(piece.originalPiece as AnyRecord)?.constructeurIds,
(piece.originalPiece as AnyRecord)?.constructeurId,
(piece.originalPiece as AnyRecord)?.constructeur,
)
const { product: resolvedProduct, productId: resolvedProductId } =
resolveProductReference(piece)
const constructeursList = resolveConstructeurs(
constructeurIds,
Array.isArray(piece.constructeurs) ? (piece.constructeurs as any[]) : [],
piece.constructeur ? [piece.constructeur as any] : [],
Array.isArray((piece.originalPiece as AnyRecord)?.constructeurs)
? ((piece.originalPiece as AnyRecord).constructeurs as any[])
: [],
(piece.originalPiece as AnyRecord)?.constructeur
? [(piece.originalPiece as AnyRecord).constructeur as any]
: [],
constructeurs.value as any,
) as any[]
const normalizedPiece = {
...piece,
product: resolvedProduct || piece.product || null,
productId: resolvedProductId || piece.productId || (piece.product as AnyRecord)?.id || null,
}
const productDisplay = getProductDisplay(normalizedPiece)
return {
...normalizedPiece,
customFields,
documents: piece.documents || [],
constructeurs: constructeursList,
constructeur: constructeursList[0] || piece.constructeur || null,
constructeurIds,
constructeurId: constructeurIds[0] || null,
typePieceId:
piece.typePieceId ||
(piece.typePiece as AnyRecord)?.id ||
null,
__productDisplay: productDisplay,
}
})
}
const transformComponentCustomFields = (componentsData: AnyRecord[]): AnyRecord[] => {
const normalizeStructureDefs = (structure: unknown) =>
structure ? normalizeStructureForEditor(structure as AnyRecord) : null
return (componentsData || []).map((component) => {
const type = (component.typeComposant as AnyRecord) || {}
const normalizedStructureDefs = [
normalizeStructureDefs((component.definition as AnyRecord)?.structure),
normalizeStructureDefs(type.structure),
]
const actualComponent = (component.originalComposant as AnyRecord) || component
const valueEntries = [
...(Array.isArray(component.customFieldValues) ? component.customFieldValues : []),
...(Array.isArray(component.customFields)
? (component.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
...(Array.isArray(actualComponent?.customFields)
? (actualComponent.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const customFields = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(component.customFields),
normalizeExistingCustomFieldDefinitions((component.definition as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions((component.typeComposant as AnyRecord)?.customFields),
normalizeExistingCustomFieldDefinitions(type.customFields),
normalizeExistingCustomFieldDefinitions(actualComponent?.customFields),
...normalizedStructureDefs.map((def) => getStructureCustomFields(def)),
),
)
const piecesTransformed = component.pieces
? transformCustomFields(component.pieces as AnyRecord[]).map((p) => ({
...p,
parentComponentName: component.name,
}))
: []
const subComponents = component.sousComposants
? transformComponentCustomFields(component.sousComposants as AnyRecord[])
: []
const constructeurIds = uniqueConstructeurIds(
component.constructeurs,
component.constructeurIds,
component.constructeurId,
component.constructeur,
actualComponent?.constructeurs,
actualComponent?.constructeurIds,
actualComponent?.constructeurId,
actualComponent?.constructeur,
)
const constructeursList = resolveConstructeurs(
constructeurIds,
Array.isArray(component.constructeurs) ? (component.constructeurs as any[]) : [],
component.constructeur ? [component.constructeur as any] : [],
Array.isArray(actualComponent?.constructeurs)
? (actualComponent.constructeurs as any[])
: [],
actualComponent?.constructeur ? [actualComponent.constructeur as any] : [],
constructeurs.value as any,
) as any[]
const { product: resolvedProduct, productId: resolvedProductId } =
resolveProductReference(component)
const normalizedComponent = {
...component,
product: resolvedProduct || component.product || null,
productId:
resolvedProductId || component.productId || (component.product as AnyRecord)?.id || null,
}
const productDisplay = getProductDisplay(normalizedComponent)
return {
...normalizedComponent,
customFields,
pieces: piecesTransformed,
subComponents,
documents: component.documents || [],
constructeurs: constructeursList,
constructeur: constructeursList[0] || component.constructeur || null,
constructeurIds,
constructeurId: constructeurIds[0] || null,
typeComposantId:
component.typeComposantId ||
(component.typeComposant as AnyRecord)?.id ||
null,
__productDisplay: productDisplay,
}
})
}
// ---------------------------------------------------------------------------
// Machine custom field methods
// ---------------------------------------------------------------------------
const syncMachineCustomFields = () => {
if (!machine.value) {
machineCustomFields.value = []
return
}
const valueEntries = [
...(Array.isArray(machine.value.customFieldValues) ? machine.value.customFieldValues : []),
...(Array.isArray(machine.value.customFields)
? (machine.value.customFields as AnyRecord[])
.map(normalizeCustomFieldValueEntry)
.filter((e) => e !== null)
: []),
]
const merged = dedupeCustomFieldEntries(
mergeCustomFieldValuesWithDefinitions(
valueEntries,
normalizeExistingCustomFieldDefinitions(machine.value.customFields),
),
).map((field: AnyRecord) => ({ ...field, readOnly: false }))
machineCustomFields.value = merged
}
const setMachineCustomFieldValue = (field: AnyRecord, value: unknown) => {
if (!field) return
field.value = value
if (field.customFieldValueId && (machine.value as AnyRecord)?.customFieldValues) {
const stored = ((machine.value as AnyRecord).customFieldValues as AnyRecord[]).find(
(fv) => fv.id === field.customFieldValueId,
)
if (stored) stored.value = value
}
}
const updateMachineCustomField = async (field: AnyRecord) => {
if (!machine.value || !field) return
const { id: customFieldId, customFieldValueId } = field
const fieldLabel = (field.name as string) || 'Champ personnalisé'
try {
if (customFieldValueId) {
const result: any = await updateCustomFieldValueApi(customFieldValueId as string, {
value: field.value ?? '',
} as any)
if (result.success) {
toast.showSuccess(`Champ "${fieldLabel}" de la machine mis à jour avec succès`)
syncMachineCustomFields()
} else {
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
}
return
}
if (!customFieldId) {
toast.showError(
'Impossible de mettre à jour ce champ personnalisé (identifiant manquant).',
)
return
}
const result: any = await upsertCustomFieldValue(
customFieldId as string,
'machine',
machine.value.id as string,
field.value ?? '',
)
if (result.success) {
const createdValue = result.data as AnyRecord
toast.showSuccess(`Champ "${fieldLabel}" de la machine mis à jour avec succès`)
if (createdValue?.id) {
if (!createdValue.customField) {
createdValue.customField = {
id: customFieldId,
name: field.name,
type: field.type,
required: field.required,
options: field.options,
}
}
field.customFieldValueId = createdValue.id
field.readOnly = false
const existingValues = Array.isArray(machine.value.customFieldValues)
? (machine.value.customFieldValues as AnyRecord[]).filter(
(item) => item.id !== createdValue.id,
)
: []
machine.value.customFieldValues = [...existingValues, createdValue]
}
syncMachineCustomFields()
} else {
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
}
} catch (error) {
console.error('Erreur lors de la mise à jour du champ personnalisé de la machine:', error)
toast.showError(`Erreur lors de la mise à jour du champ "${fieldLabel}"`)
}
}
const updatePieceCustomField = async (fieldUpdate: AnyRecord) => {
try {
const result: any = await upsertCustomFieldValue(
fieldUpdate.fieldId as string,
'piece',
fieldUpdate.pieceId as string,
fieldUpdate.value,
)
if (result.success) {
toast.showSuccess('Champ personnalisé mis à jour avec succès')
} else {
toast.showError('Erreur lors de la mise à jour du champ personnalisé')
}
} catch (error) {
toast.showError('Erreur lors de la mise à jour du champ personnalisé')
console.error('Erreur lors de la mise à jour du champ personnalisé:', error)
}
}
const saveAllMachineCustomFields = async () => {
if (!machine.value) return
const fields = Array.isArray(machineCustomFields.value) ? machineCustomFields.value : []
const fieldsToSave = fields.filter(
(field) => field.value !== undefined && field.value !== null && String(field.value).trim() !== '',
)
for (const field of fieldsToSave) {
const { id: customFieldId, customFieldValueId } = field
try {
if (customFieldValueId) {
await updateCustomFieldValueApi(customFieldValueId as string, {
value: field.value ?? '',
} as any)
} else if (customFieldId) {
const result: any = await upsertCustomFieldValue(
customFieldId as string,
'machine',
machine.value.id as string,
field.value ?? '',
)
if (result.success) {
const createdValue = result.data as AnyRecord
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
if (!createdValue.customField) {
createdValue.customField = {
id: customFieldId,
name: field.name,
type: field.type,
required: field.required,
options: field.options,
}
}
const existingValues = Array.isArray(machine.value.customFieldValues)
? (machine.value.customFieldValues as AnyRecord[]).filter(
(item) => item.id !== createdValue.id,
)
: []
machine.value.customFieldValues = [...existingValues, createdValue]
}
}
}
} catch (error) {
console.error('Erreur lors de la sauvegarde du champ personnalisé:', error)
throw error
}
}
}
return {
// State
machineCustomFields,
// Computed
visibleMachineCustomFields,
// Transform functions
transformCustomFields,
transformComponentCustomFields,
// Methods
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
saveAllMachineCustomFields,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
/**
* Machine detail — document management sub-composable.
*
* Handles document loading, upload, delete and preview state.
*/
import { ref, computed } from 'vue'
import { useDocuments } from '~/composables/useDocuments'
import { canPreviewDocument } from '~/utils/documentPreview'
type AnyRecord = Record<string, unknown>
interface MachineDetailDocumentsDeps {
machine: Ref<AnyRecord | null>
}
export function useMachineDetailDocuments(deps: MachineDetailDocumentsDeps) {
const { machine } = deps
const {
uploadDocuments,
deleteDocument,
loadDocumentsByMachine,
loadDocumentsByProduct,
} = useDocuments()
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const machineDocumentFiles = ref<File[]>([])
const machineDocumentsUploading = ref(false)
const machineDocumentsLoaded = ref(false)
const previewDocument = ref<AnyRecord | null>(null)
const previewVisible = ref(false)
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const machineDocumentsList = computed(
() => ((machine.value as AnyRecord)?.documents as AnyRecord[]) || [],
)
// ---------------------------------------------------------------------------
// Methods
// ---------------------------------------------------------------------------
const refreshMachineDocuments = async () => {
if (!machine.value?.id) return
const result: any = await loadDocumentsByMachine(machine.value.id as string, { updateStore: false })
if (result.success && machine.value) {
machine.value.documents = result.data || []
machineDocumentsLoaded.value = true
}
}
const handleMachineFilesAdded = async (files: File[]) => {
if (!files.length || !machine.value?.id) return
machineDocumentsUploading.value = true
try {
const result: any = await uploadDocuments(
{ files, context: { machineId: machine.value.id } } as any,
{ updateStore: false },
)
if (result.success && machine.value) {
const newDocs = (result.data as AnyRecord[]) || []
machine.value.documents = [
...newDocs,
...((machine.value.documents as AnyRecord[]) || []),
]
machineDocumentFiles.value = []
}
} finally {
machineDocumentsUploading.value = false
}
}
const removeMachineDocument = async (documentId: string) => {
if (!documentId) return
const result: any = await deleteDocument(documentId, { updateStore: false })
if (result.success && machine.value) {
machine.value.documents = ((machine.value.documents as AnyRecord[]) || []).filter(
(doc) => doc.id !== documentId,
)
}
}
const openPreview = (doc: AnyRecord) => {
if (!canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const loadProductDocuments = async (machineProductLinks: AnyRecord[]) => {
const productIds = machineProductLinks
.map((link) => {
const p = link.product as AnyRecord | string | null
if (typeof p === 'string') return p.split('/').pop() || null
return (p as AnyRecord)?.id as string | null
})
.filter((id): id is string => !!id)
const results = await Promise.allSettled(
productIds.map(async (id) => {
const result: any = await loadDocumentsByProduct(id, { updateStore: false })
if (result.success && Array.isArray(result.data)) {
return { id, docs: result.data as AnyRecord[] }
}
return { id, docs: [] }
}),
)
const map = new Map<string, AnyRecord[]>()
results.forEach((r) => {
if (r.status === 'fulfilled' && r.value.docs.length) {
map.set(r.value.id, r.value.docs)
}
})
return map
}
return {
// State
machineDocumentFiles,
machineDocumentsUploading,
machineDocumentsLoaded,
previewDocument,
previewVisible,
// Computed
machineDocumentsList,
// Methods
refreshMachineDocuments,
handleMachineFilesAdded,
removeMachineDocument,
openPreview,
closePreview,
loadProductDocuments,
}
}

View File

@@ -0,0 +1,306 @@
/**
* Machine detail — hierarchy & link management sub-composable.
*
* Handles machine hierarchy building, component/piece tree resolution,
* flatten helpers, find-by-id utilities, and structure link CRUD.
*/
import { ref, computed } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import {
resolveIdentifier,
} from '~/shared/utils/productDisplayUtils'
import {
buildMachineHierarchyFromLinks,
resolveLinkArray,
} from '~/composables/useMachineHierarchy'
type AnyRecord = Record<string, unknown>
interface MachineDetailHierarchyDeps {
machineId: string
machine: Ref<AnyRecord | null>
constructeurs: Ref<unknown[]>
findProductById: (id: string | null | undefined) => AnyRecord | null
transformComponentCustomFields: (data: AnyRecord[]) => AnyRecord[]
transformCustomFields: (data: AnyRecord[]) => AnyRecord[]
syncMachineCustomFields: () => void
}
export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
const {
machineId,
machine,
constructeurs,
findProductById,
transformComponentCustomFields,
transformCustomFields,
syncMachineCustomFields,
} = deps
const { get, post: apiPost, delete: apiDel } = useApi()
const toast = useToast()
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const components = ref<AnyRecord[]>([])
const pieces = ref<AnyRecord[]>([])
const machineComponentLinks = ref<AnyRecord[]>([])
const machinePieceLinks = ref<AnyRecord[]>([])
const machineProductLinks = ref<AnyRecord[]>([])
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const flattenComponents = (list: AnyRecord[] = []): AnyRecord[] => {
const result: AnyRecord[] = []
const traverse = (items: AnyRecord[]) => {
items.forEach((item) => {
result.push(item)
if (Array.isArray(item.subComponents) && item.subComponents.length) {
traverse(item.subComponents as AnyRecord[])
}
})
}
traverse(list)
return result
}
const findComponentById = (items: AnyRecord[] | undefined, id: string): AnyRecord | null => {
for (const item of items || []) {
if (item.id === id) return item
const found = findComponentById(item.subComponents as AnyRecord[] | undefined, id)
if (found) return found
}
return null
}
const findPieceById = (pieceId: string): AnyRecord | null => {
const direct = pieces.value.find((p) => p.id === pieceId)
if (direct) return direct
const searchInComponents = (items: AnyRecord[]): AnyRecord | null => {
for (const item of items || []) {
const match = ((item.pieces as AnyRecord[]) || []).find((p) => p.id === pieceId)
if (match) return match
const nested = searchInComponents((item.subComponents as AnyRecord[]) || [])
if (nested) return nested
}
return null
}
return searchInComponents(components.value)
}
// ---------------------------------------------------------------------------
// Hierarchy & links
// ---------------------------------------------------------------------------
const applyMachineLinks = (source: AnyRecord): boolean => {
const container = (source?.machine as AnyRecord) ?? null
const componentLinksData =
resolveLinkArray(source, ['componentLinks', 'machineComponentLinks']) ??
resolveLinkArray(container, ['componentLinks', 'machineComponentLinks'])
const pieceLinksData =
resolveLinkArray(source, ['pieceLinks', 'machinePieceLinks']) ??
resolveLinkArray(container, ['pieceLinks', 'machinePieceLinks'])
const productLinksData =
resolveLinkArray(source, ['productLinks', 'machineProductLinks']) ??
resolveLinkArray(container, ['productLinks', 'machineProductLinks'])
if (componentLinksData === null && pieceLinksData === null && productLinksData === null) {
return false
}
const normalizedComponentLinks = (componentLinksData ?? []) as AnyRecord[]
const normalizedPieceLinks = (pieceLinksData ?? []) as AnyRecord[]
const normalizedProductLinks = (productLinksData ?? []) as AnyRecord[]
machineComponentLinks.value = normalizedComponentLinks
machinePieceLinks.value = normalizedPieceLinks
machineProductLinks.value = normalizedProductLinks
const { components: hierarchy, machinePieces: machineLevelPieces } =
buildMachineHierarchyFromLinks(
normalizedComponentLinks,
normalizedPieceLinks,
findProductById as any,
constructeurs.value as any,
)
components.value = transformComponentCustomFields(hierarchy as AnyRecord[])
pieces.value = transformCustomFields(machineLevelPieces as AnyRecord[])
return true
}
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const flattenedComponents = computed(() => flattenComponents(components.value))
const machinePieces = computed(() => {
return pieces.value.filter((piece) => {
const parentLinkId = resolveIdentifier(
piece.parentComponentLinkId,
(piece.machinePieceLink as AnyRecord)?.parentComponentLinkId,
piece.parentLinkId,
)
if (parentLinkId) return false
return !piece.composantId
})
})
// ---------------------------------------------------------------------------
// Structure reload
// ---------------------------------------------------------------------------
const reloadMachineStructure = async () => {
const result: any = await get(`/machines/${machineId}/structure`)
if (result.success) {
const machinePayload =
result.data?.machine && typeof result.data.machine === 'object'
? result.data.machine
: result.data
if (machinePayload && typeof machinePayload === 'object') {
machine.value = {
...machine.value,
...machinePayload,
documents: machinePayload.documents || (machine.value as AnyRecord)?.documents || [],
customFieldValues: machinePayload.customFieldValues || (machine.value as AnyRecord)?.customFieldValues || [],
}
const linksApplied = applyMachineLinks(result.data)
if (linksApplied && machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
machine.value.productLinks = machineProductLinks.value
}
syncMachineCustomFields()
}
}
}
// ---------------------------------------------------------------------------
// Structure link CRUD
// ---------------------------------------------------------------------------
const addComponentLink = async (composantId: string) => {
const result: any = await apiPost('/machine_component_links', {
machine: `/api/machines/${machineId}`,
composant: `/api/composants/${composantId}`,
})
if (result.success) {
toast.showSuccess('Composant ajouté à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout du composant')
}
return result
}
const removeComponentLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_component_links/${linkId}`)
if (result.success) {
toast.showSuccess('Composant retiré de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression du composant')
}
return result
}
const addPieceLink = async (pieceId: string, parentComponentLinkId?: string) => {
const payload: any = {
machine: `/api/machines/${machineId}`,
piece: `/api/pieces/${pieceId}`,
}
if (parentComponentLinkId) {
payload.parentLink = `/api/machine_component_links/${parentComponentLinkId}`
}
const result: any = await apiPost('/machine_piece_links', payload)
if (result.success) {
toast.showSuccess('Pièce ajoutée à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout de la pièce')
}
return result
}
const removePieceLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_piece_links/${linkId}`)
if (result.success) {
toast.showSuccess('Pièce retirée de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression de la pièce')
}
return result
}
const addProductLink = async (productId: string, parentComponentLinkId?: string, parentPieceLinkId?: string) => {
const payload: any = {
machine: `/api/machines/${machineId}`,
product: `/api/products/${productId}`,
}
if (parentComponentLinkId) {
payload.parentComponentLink = `/api/machine_component_links/${parentComponentLinkId}`
}
if (parentPieceLinkId) {
payload.parentPieceLink = `/api/machine_piece_links/${parentPieceLinkId}`
}
const result: any = await apiPost('/machine_product_links', payload)
if (result.success) {
toast.showSuccess('Produit ajouté à la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de l\'ajout du produit')
}
return result
}
const removeProductLink = async (linkId: string) => {
const result: any = await apiDel(`/machine_product_links/${linkId}`)
if (result.success) {
toast.showSuccess('Produit retiré de la machine')
await reloadMachineStructure()
} else {
toast.showError('Erreur lors de la suppression du produit')
}
return result
}
return {
// State
components,
pieces,
machineComponentLinks,
machinePieceLinks,
machineProductLinks,
// Computed
flattenedComponents,
machinePieces,
// Helpers
flattenComponents,
findComponentById,
findPieceById,
// Hierarchy
applyMachineLinks,
// Structure link management
reloadMachineStructure,
addComponentLink,
removeComponentLink,
addPieceLink,
removePieceLink,
addProductLink,
removeProductLink,
}
}

View File

@@ -0,0 +1,132 @@
/**
* Machine detail — product display sub-composable.
*
* Handles product resolution, display helpers, supplier info,
* and machine-level direct product links.
*/
import { computed } from 'vue'
import { useProducts } from '~/composables/useProducts'
import {
resolveProductReference as _resolveProductReference,
getProductDisplay as _getProductDisplay,
getProductSuppliersLabel,
getProductPriceLabel,
} from '~/shared/utils/productDisplayUtils'
import {
resolveConstructeurs,
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
type AnyRecord = Record<string, unknown>
interface MachineDetailProductsDeps {
machineProductLinks: Ref<AnyRecord[]>
productDocumentsMap: Ref<Map<string, AnyRecord[]>>
constructeurs: Ref<unknown[]>
}
export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
const { machineProductLinks, productDocumentsMap, constructeurs } = deps
const { products, loadProducts } = useProducts()
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const productInventory = computed(() => products.value || [])
const productById = computed(() => {
const map = new Map<string, AnyRecord>()
;(productInventory.value as AnyRecord[]).forEach((product: AnyRecord) => {
if (product?.id) map.set(product.id as string, product)
})
return map
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const findProductById = (productId: string | null | undefined): AnyRecord | null => {
if (!productId) return null
return productById.value.get(productId) || null
}
const resolveProductReference = (source: AnyRecord) =>
_resolveProductReference(source, findProductById as any)
const getProductDisplay = (source: AnyRecord) =>
_getProductDisplay(source, findProductById as any)
// ---------------------------------------------------------------------------
// Machine direct products
// ---------------------------------------------------------------------------
const machineDirectProducts = computed(() => {
return machineProductLinks.value.map((link) => {
const productObj = link.product as AnyRecord | string | null
let resolved: AnyRecord | null = null
let productId: string | null = null
if (typeof productObj === 'string') {
productId = productObj.split('/').pop() || null
resolved = productId ? findProductById(productId) : null
} else if (productObj && typeof productObj === 'object') {
productId = (productObj as AnyRecord)?.id as string | null
// Prefer the embedded product from the structure endpoint — it has richer
// data (typeProduct as object, supplierPrice, constructeurs) than the
// global products cache which may store typeProduct as an IRI string.
const cached = productId ? findProductById(productId) : null
resolved = productObj as AnyRecord
if (cached) {
// Merge: use embedded as base, overlay any non-null cached fields
resolved = { ...resolved, ...Object.fromEntries(
Object.entries(cached as AnyRecord).filter(([, v]) => v != null && v !== ''),
) }
// But always prefer the embedded typeProduct when it's an object
if (productObj.typeProduct && typeof productObj.typeProduct === 'object') {
resolved.typeProduct = productObj.typeProduct
}
}
}
const cIds = uniqueConstructeurIds(
resolved?.constructeurs,
resolved?.constructeurIds,
)
const resolvedConstructeurs = resolveConstructeurs(
cIds,
resolved?.constructeurs as any[] || [],
constructeurs.value as any,
)
return {
id: (resolved?.id as string) || productId || null,
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
name: (resolved?.name as string) || 'Produit inconnu',
reference: (resolved?.reference as string) || null,
supplierLabel: resolvedConstructeurs.length
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
: getProductSuppliersLabel(resolved),
priceLabel: resolved ? getProductPriceLabel(resolved) : null,
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
}
})
})
return {
// Computed
productInventory,
productById,
machineDirectProducts,
// Helpers
findProductById,
resolveProductReference,
getProductDisplay,
// Loading
loadProducts,
}
}

View File

@@ -0,0 +1,244 @@
/**
* Machine detail page — update/mutation methods.
*
* Extracted from useMachineDetailData.ts to keep the orchestrator under 500 lines.
*/
import type { Ref } from 'vue'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
type AnyRecord = Record<string, unknown>
export interface UseMachineDetailUpdatesDeps {
machine: Ref<AnyRecord | null>
machineName: Ref<string>
machineReference: Ref<string>
machineSiteId: Ref<string>
machineConstructeurIds: Ref<string[]>
constructeurLinks: Ref<ConstructeurLinkEntry[]>
originalConstructeurLinks: Ref<ConstructeurLinkEntry[]>
machineDocumentsLoaded: Ref<boolean>
machineComponentLinks: Ref<AnyRecord[]>
machinePieceLinks: Ref<AnyRecord[]>
machineProductLinks: Ref<AnyRecord[]>
applyMachineLinks: (data: AnyRecord) => boolean
refreshMachineDocuments: () => Promise<void>
transformComponentCustomFields: (items: AnyRecord[]) => AnyRecord[]
transformCustomFields: (items: AnyRecord[]) => AnyRecord[]
loadProductDocuments: () => Promise<void>
upsertCustomFieldValue: (
fieldId: string,
entityType: string,
entityId: string,
value: unknown,
) => Promise<unknown>
updateMachineApi: (id: string, data: any) => Promise<unknown>
updateComposantApi: (id: string, data: any) => Promise<unknown>
updatePieceApi: (id: string, data: any) => Promise<unknown>
apiPatch: (endpoint: string, data?: unknown) => Promise<any>
toast: { showInfo: (msg: string) => void }
syncLinks: (
entityType: 'machine' | 'piece' | 'composant' | 'product',
entityId: string,
originalLinks: ConstructeurLinkEntry[],
formLinks: ConstructeurLinkEntry[],
) => Promise<void>
}
export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const {
machine,
machineName,
machineReference,
machineSiteId,
machineConstructeurIds,
constructeurLinks,
originalConstructeurLinks,
machineComponentLinks,
machinePieceLinks,
applyMachineLinks,
loadProductDocuments,
transformComponentCustomFields,
transformCustomFields,
upsertCustomFieldValue,
updateMachineApi,
updateComposantApi,
updatePieceApi,
apiPatch,
toast,
syncLinks,
} = deps
const updateMachineInfo = async () => {
if (!machine.value) return
try {
const result: any = await updateMachineApi(machine.value.id as string, {
name: machineName.value,
reference: machineReference.value,
siteId: machineSiteId.value || undefined,
} as any)
if (result.success) {
const machinePayload =
result.data?.machine && typeof result.data.machine === 'object'
? result.data.machine
: result.data
if (machinePayload && typeof machinePayload === 'object') {
machine.value = {
...machine.value,
...machinePayload,
documents: machinePayload.documents || machine.value.documents || [],
customFieldValues: machinePayload.customFieldValues || machine.value.customFieldValues || [],
}
const linksApplied = applyMachineLinks(result.data)
if (linksApplied && machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
}
loadProductDocuments().catch(() => {})
}
}
// Sync constructeur links after entity save
await syncLinks('machine', machine.value!.id as string, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
} catch (error) {
console.error('Erreur lors de la mise à jour de la machine:', error)
}
}
const updateComponent = async (updatedComponent: AnyRecord) => {
try {
const cIds = uniqueConstructeurIds(
updatedComponent.constructeurIds,
updatedComponent.constructeurId,
updatedComponent.constructeur,
)
const productId = updatedComponent.productId
? String(updatedComponent.productId)
: null
const prixStr =
updatedComponent.prix !== null &&
updatedComponent.prix !== undefined &&
String(updatedComponent.prix).trim() !== ''
? String(updatedComponent.prix)
: null
const result: any = await updateComposantApi(updatedComponent.id as string, {
name: updatedComponent.name,
reference: updatedComponent.reference,
constructeurIds: cIds,
prix: prixStr,
productId,
} as any)
if (result.success) {
const transformed = transformComponentCustomFields([result.data])[0]
Object.assign(updatedComponent, transformed)
}
} catch (error) {
console.error('Erreur lors de la mise à jour du composant:', error)
}
}
const _buildAndUpdatePiece = async (updatedPiece: AnyRecord) => {
const cIds = uniqueConstructeurIds(
updatedPiece.constructeurIds,
updatedPiece.constructeurId,
updatedPiece.constructeur,
)
const productId = updatedPiece.productId ? String(updatedPiece.productId) : null
const prixStr =
updatedPiece.prix !== null &&
updatedPiece.prix !== undefined &&
String(updatedPiece.prix).trim() !== ''
? String(updatedPiece.prix)
: null
const result: any = await updatePieceApi(updatedPiece.id as string, {
name: updatedPiece.name,
reference: updatedPiece.reference || null,
constructeurIds: cIds,
prix: prixStr,
productId,
} as any)
if (result.success) {
const transformed = transformCustomFields([result.data])[0]
Object.assign(updatedPiece, transformed)
}
return result
}
const updatePieceFromComponent = async (updatedPiece: AnyRecord) => {
try {
const result = await _buildAndUpdatePiece(updatedPiece)
if (result?.success && updatedPiece.customFields) {
const fieldsToSave = (updatedPiece.customFields as AnyRecord[]).filter(
(field) => field.value !== undefined,
)
if (fieldsToSave.length) {
await Promise.allSettled(
fieldsToSave.map((field) =>
upsertCustomFieldValue(
field.id as string,
'piece',
updatedPiece.id as string,
field.value,
),
),
)
}
}
// Update slot quantity if this is a composant structure piece
const slotId = updatedPiece.slotId as string | null
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
if (slotId && quantity !== null) {
await apiPatch(`/composant-piece-slots/${slotId}`, { quantity })
}
} catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error)
}
}
const updatePieceInfo = async (updatedPiece: AnyRecord) => {
try {
await _buildAndUpdatePiece(updatedPiece)
// Update link quantity if this is a direct machine piece
const linkId = updatedPiece.linkId || updatedPiece.machinePieceLinkId
const quantity = typeof updatedPiece.quantity === 'number' ? Math.max(1, updatedPiece.quantity) : null
if (linkId && quantity !== null) {
await apiPatch(`/machine_piece_links/${linkId}`, { quantity })
}
} catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error)
}
}
const handleMachineConstructeurChange = (value: unknown) => {
const newIds = uniqueConstructeurIds(value)
machineConstructeurIds.value = newIds
// Sync constructeurLinks: keep existing entries, add new ones
const existingMap = new Map(constructeurLinks.value.map(l => [l.constructeurId, l]))
constructeurLinks.value = newIds.map(id =>
existingMap.get(id) ?? { constructeurId: id, supplierReference: null },
)
}
const editComponent = () => {
toast.showInfo('La modification des composants sera bientôt disponible')
}
const editPiece = () => {
toast.showInfo('La modification des pièces sera bientôt disponible')
}
return {
updateMachineInfo,
updateComponent,
updatePieceFromComponent,
updatePieceInfo,
handleMachineConstructeurChange,
editComponent,
editPiece,
}
}

View File

@@ -181,6 +181,7 @@ export const buildMachineHierarchyFromLinks = (
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId), parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId), parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId), parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
definition: appliedPiece.definition || originalPiece?.definition || {}, definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [], customFields: appliedPiece.customFields || [],
} }
@@ -214,10 +215,39 @@ export const buildMachineHierarchyFromLinks = (
const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string const componentName = (compOverrides?.name || appliedComponent.name || (appliedComponent.definition as AnyRecord)?.alias || (appliedComponent.definition as AnyRecord)?.name || originalComponent?.name || 'Composant') as string
const pieces = Array.isArray(link.pieceLinks) const linkedPieces = Array.isArray(link.pieceLinks)
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[] ? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
: [] : []
// If no linked pieces exist, build read-only entries from the composant's structure
const structurePieceDefs = (!linkedPieces.length && appliedComponent.structure && typeof appliedComponent.structure === 'object')
? (Array.isArray((appliedComponent.structure as AnyRecord).pieces) ? (appliedComponent.structure as AnyRecord).pieces as AnyRecord[] : [])
: []
const structurePieces = structurePieceDefs.map((def, index) => {
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
return {
...(resolved || {}),
id: resolved?.id || `structure-piece-${composantId}-${index}`,
pieceId: resolved?.id || null,
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`,
reference: resolved?.reference || definition.reference || def.reference || null,
prix: resolved?.prix ?? null,
constructeurs: resolved?.constructeurs || [],
documents: [],
quantity,
slotId: def.slotId || definition.slotId || null,
typePieceId: resolved?.typePieceId || definition.typePieceId || def.typePieceId || null,
typePiece: resolved?.typePiece || null,
parentComponentLinkId: machineComponentLinkId,
parentComponentName: componentName,
_structurePiece: true,
}
}) as AnyRecord[]
const pieces = linkedPieces.length ? linkedPieces : structurePieces
const subComponents = Array.isArray(link.childLinks) const subComponents = Array.isArray(link.childLinks)
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[] ? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
: [] : []
@@ -243,6 +273,7 @@ export const buildMachineHierarchyFromLinks = (
originalComposant: originalComponent, originalComposant: originalComponent,
machineComponentLink: link, machineComponentLink: link,
machineComponentLinkId, machineComponentLinkId,
linkId: machineComponentLinkId,
componentLinkId: machineComponentLinkId, componentLinkId: machineComponentLinkId,
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId), parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId), parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),

View File

@@ -1,7 +1,6 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi, type ApiResponse } from './useApi' import { useApi, type ApiResponse } from './useApi'
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
@@ -92,7 +91,7 @@ export function useMachines() {
const createMachine = async (machineData: Partial<Machine>): Promise<ApiResponse> => { const createMachine = async (machineData: Partial<Machine>): Promise<ApiResponse> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData)) const normalizedPayload = normalizeRelationIds(machineData as Record<string, unknown>)
const result = await post('/machines', normalizedPayload) const result = await post('/machines', normalizedPayload)
if (result.success) { if (result.success) {
const createdMachine = normalizeMachineResponse(result.data) || const createdMachine = normalizeMachineResponse(result.data) ||
@@ -116,7 +115,7 @@ export function useMachines() {
const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => { const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData)) const normalizedPayload = normalizeRelationIds(machineData as Record<string, unknown>)
const result = await patch(`/machines/${id}`, normalizedPayload) const result = await patch(`/machines/${id}`, normalizedPayload)
if (result.success) { if (result.success) {
const updatedMachine = normalizeMachineResponse(result.data) || const updatedMachine = normalizeMachineResponse(result.data) ||

View File

@@ -0,0 +1,482 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from '#imports'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useCustomFields } from '~/composables/useCustomFields'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { usePieceHistory } from '~/composables/usePieceHistory'
import { extractRelationId } from '~/shared/apiRelations'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import type { PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import {
getStructureProducts,
buildProductRequirementDescriptions,
buildProductRequirementEntries,
resizeProductSelections,
areProductSelectionsFilled,
applyProductSelection,
collectNormalizedProductIds,
} from '~/shared/utils/pieceProductSelectionUtils'
import { getModelType } from '~/services/modelTypes'
import {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null
customFields?: Array<Record<string, any>>
}
export function usePieceEdit(pieceId: string) {
const { canEdit } = usePermissions()
const router = useRouter()
const { get } = useApi()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { updatePiece } = usePieces()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const { ensureConstructeurs } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = usePieceHistory()
const piece = ref<any | null>(null)
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const pieceDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
typePiece: 'Catégorie',
product: 'Produit lié',
productIds: 'Produits liés',
constructeurIds: 'Fournisseurs',
}
const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
})
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const constructeurIdsFromForm = computed(() => constructeurIdsFromLinks(constructeurLinks.value))
const productSelections = ref<(string | null)[]>([])
const customFieldInputs = ref<CustomFieldInput[]>([])
const resolvedStructure = computed<PieceModelStructure | null>(() =>
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
)
const refreshCustomFieldInputs = (
structureOverride?: PieceModelStructure | null,
valuesOverride?: any[] | null,
) => {
const structure = structureOverride ?? resolvedStructure.value ?? null
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values)
}
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) {
return
}
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteDocument(documentId, { updateStore: false })
if (result.success) {
pieceDocuments.value = pieceDocuments.value.filter((doc) => doc.id !== documentId)
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !piece.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadDocuments(
{
files,
context: { pieceId: piece.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
}
}
finally {
uploadingDocuments.value = false
}
}
const refreshDocuments = async () => {
if (!piece.value?.id) {
pieceDocuments.value = []
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
if (result.success) {
pieceDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
}
}
finally {
loadingDocuments.value = false
}
}
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const structureProducts = computed(() =>
getStructureProducts(resolvedStructure.value),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const productRequirementDescriptions = computed(() =>
buildProductRequirementDescriptions(structureProducts.value),
)
const ensureProductSelections = (count: number) => {
productSelections.value = resizeProductSelections(productSelections.value, count)
}
let pendingProductIds: string[] = []
const productRequirementEntries = computed(() =>
buildProductRequirementEntries(structureProducts.value, 'piece-product-requirement'),
)
const productSelectionsFilled = computed(() =>
areProductSelectionsFilled(
requiresProductSelection.value,
productRequirementEntries.value,
productSelections.value,
),
)
const setProductSelection = (index: number, value: string | null) => {
productSelections.value = applyProductSelection(productSelections.value, index, value)
}
watch(structureProducts, (products) => {
ensureProductSelections(products.length)
if (!pendingProductIds.length || products.length === 0) {
return
}
const next = Array.from(
{ length: products.length },
(_, index) => pendingProductIds[index] ?? null,
)
productSelections.value = next
pendingProductIds = []
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() =>
Boolean(
canEdit.value
&& piece.value
&& editionForm.name
&& requiredCustomFieldsFilled.value
&& productSelectionsFilled.value
&& !saving.value,
),
)
const fetchPiece = async () => {
if (!pieceId || typeof pieceId !== 'string') {
piece.value = null
pieceDocuments.value = []
return
}
const result = await get(`/pieces/${pieceId}`)
if (result.success) {
piece.value = result.data
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
// Use cached type from loadPieceTypes() instead of separate getModelType() call
loadPieceTypeDetailsFromCache(result.data)
// History is non-blocking — template handles its own loading state
loadHistory(result.data.id).catch(() => {})
}
else {
piece.value = null
pieceDocuments.value = []
}
}
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
const typeId = currentPiece?.typePieceId
|| extractRelationId(currentPiece?.typePiece)
|| ''
if (!typeId) {
pieceTypeDetails.value = null
return
}
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
if (cachedType) {
pieceTypeDetails.value = cachedType
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
return
}
// Fallback: fetch if not in cache (edge case)
getModelType(typeId).then((type) => {
if (type && typeof type === 'object') {
pieceTypeDetails.value = type
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
}
}).catch(() => {
pieceTypeDetails.value = null
})
}
let initialized = false
watch(
[piece, selectedType],
([currentPiece, _currentType]) => {
if (!currentPiece || initialized) {
return
}
const resolvedTypeId = currentPiece.typePieceId
|| extractRelationId(currentPiece.typePiece)
|| ''
if (resolvedTypeId && !currentPiece.typePieceId) {
currentPiece.typePieceId = resolvedTypeId
}
selectedTypeId.value = resolvedTypeId
editionForm.name = currentPiece.name || ''
editionForm.description = currentPiece.description || ''
editionForm.reference = currentPiece.reference || ''
// Load constructeur links
fetchLinks('piece', pieceId).then((links) => {
constructeurLinks.value = links
originalConstructeurLinks.value = links.map(l => ({ ...l }))
editionForm.constructeurIds = constructeurIdsFromLinks(links)
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
})
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
? currentPiece.productIds.map((id: unknown) => String(id))
: currentPiece.product?.id || currentPiece.productId
? [String(currentPiece.product?.id || currentPiece.productId)]
: []
pendingProductIds = existingProductIds
ensureProductSelections(structureProducts.value.length)
if (existingProductIds.length && structureProducts.value.length) {
const next = Array.from(
{ length: structureProducts.value.length },
(_, index) => existingProductIds[index] ?? null,
)
productSelections.value = next
pendingProductIds = []
}
// After setting selectedTypeId, read selectedType.value (now updated) instead of
// the stale destructured currentType which was captured before the ID change.
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
initialized = true
},
{ immediate: true },
)
watch(selectedType, (currentType) => {
if (!piece.value || !currentType) {
return
}
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
})
watch(resolvedStructure, (currentStructure) => {
if (!piece.value) {
return
}
ensureProductSelections(structureProducts.value.length)
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
})
const submitEdition = async () => {
if (!piece.value) {
return
}
if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const rawPrice = typeof editionForm.prix === 'string'
? editionForm.prix.trim()
: editionForm.prix === null || editionForm.prix === undefined
? ''
: String(editionForm.prix).trim()
const payload: Record<string, any> = {
name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
}
const reference = editionForm.reference.trim()
payload.reference = reference ? reference : null
const normalizedProductIds = collectNormalizedProductIds(
productRequirementEntries.value,
productSelections.value,
)
payload.productIds = normalizedProductIds
payload.productId = normalizedProductIds[0] || null
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.prix = String(parsed)
}
}
else {
payload.prix = null
}
saving.value = true
try {
const result = await updatePiece(piece.value.id, payload)
if (result.success && result.data) {
const updatedPiece = result.data as Record<string, any>
await _saveCustomFieldValues(
'piece',
updatedPiece.id,
[
updatedPiece?.typePiece?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await syncLinks('piece', piece.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
toast.showSuccess('Pièce mise à jour avec succès.')
}
}
catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour de la pièce')
}
finally {
saving.value = false
}
}
onMounted(async () => {
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
loading.value = false
})
return {
// State
piece,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
pieceDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
constructeurLinks,
originalConstructeurLinks,
constructeurIdsFromForm,
productSelections,
customFieldInputs,
canEdit,
// Computed
pieceTypeList,
selectedType,
resolvedStructure,
structureProducts,
productRequirementDescriptions,
productRequirementEntries,
canSubmit,
historyFieldLabels,
// History
history,
historyLoading,
historyError,
// Methods
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
setProductSelection,
submitEdition,
fetchPiece,
formatPieceStructurePreview,
}
}

View File

@@ -0,0 +1,440 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type {
PieceModelCustomField,
PieceModelCustomFieldType,
PieceModelProduct,
PieceModelStructure,
PieceModelStructureEditorField,
} from '~/shared/types/inventory'
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
import { useProductTypes } from '~/composables/useProductTypes'
export type EditorField = PieceModelStructureEditorField & { uid: string }
export type EditorProduct = {
uid: string
typeProductId: string
typeProductLabel: string
familyCode: string
}
interface Deps {
props: {
modelValue?: PieceModelStructure | null
}
emit: (event: 'update:modelValue', value: PieceModelStructure) => void
}
// --- Pure helpers ---
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
Array.isArray(value) ? value : []
const normalizeLineEndings = (value: string): string =>
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const safeClone = <T,>(value: T, fallback: T): T => {
try {
return JSON.parse(JSON.stringify(value ?? fallback)) as T
}
catch {
return JSON.parse(JSON.stringify(fallback)) as T
}
}
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
if (!structure || typeof structure !== 'object') {
return {}
}
const entries = Object.entries(structure).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return safeClone(Object.fromEntries(entries), {})
}
let uidCounter = 0
const createUid = (scope: 'field' | 'product'): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
uidCounter += 1
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
}
// --- Hydration ---
const toEditorField = (
input: Partial<PieceModelStructureEditorField> | null | undefined,
index: number,
): EditorField => {
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
const optionsText = normalizeLineEndings(
typeof input?.optionsText === 'string'
? input.optionsText
: Array.isArray(input?.options)
? input.options.join('\n')
: '',
)
return {
uid: createUid('field'),
name: typeof input?.name === 'string' ? input.name : '',
type: baseType as PieceModelCustomFieldType,
required: Boolean(input?.required),
optionsText,
defaultValue:
input?.defaultValue !== undefined && input.defaultValue !== null && input.defaultValue !== ''
? String(input.defaultValue)
: null,
...(typeof input?.id === 'string' && input.id ? { id: input.id } : {}),
...(typeof input?.customFieldId === 'string' && input.customFieldId ? { customFieldId: input.customFieldId } : {}),
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
}
}
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
const source = ensureArray(structure?.customFields)
return source
.map((field, index) => toEditorField(field, index))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
}
const toEditorProduct = (
input: Partial<PieceModelProduct> | null | undefined,
): EditorProduct => ({
uid: createUid('product'),
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
typeProductLabel:
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
})
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
const source = Array.isArray(structure?.products) ? structure?.products : []
return source.map(product => toEditorProduct(product))
}
// --- Payload ---
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
list.map((field, index) => ({
...field,
orderIndex: index,
}))
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
if (!typeProductId && !familyCode) {
return null
}
const payload: PieceModelProduct = {}
if (typeProductId) {
payload.typeProductId = typeProductId
}
if (familyCode) {
payload.familyCode = familyCode
}
if (product.typeProductLabel) {
payload.typeProductLabel = product.typeProductLabel
}
return payload
}
const buildPayload = (
fieldsSource: EditorField[],
productsSource: EditorProduct[],
restSource: Record<string, unknown>,
): PieceModelStructure => {
const normalizedFields = fieldsSource
.map<PieceModelCustomField | null>((field, index) => {
const name = field.name.trim()
if (!name) {
return null
}
const type = (field.type || 'text') as PieceModelCustomFieldType
const required = Boolean(field.required)
const payload: PieceModelCustomField = {
name,
type,
required,
orderIndex: index,
}
if (field.id) {
payload.id = field.id
}
if (field.customFieldId) {
payload.customFieldId = field.customFieldId
}
if (field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '') {
payload.defaultValue = String(field.defaultValue)
}
if (type === 'select') {
const options = normalizeLineEndings(field.optionsText)
.split('\n')
.map(option => option.trim())
.filter(option => option.length > 0)
if (options.length > 0) {
payload.options = options
}
}
return payload
})
.filter((field): field is PieceModelCustomField => Boolean(field))
const normalizedProducts = productsSource
.map(product => normalizeProductEntry(product))
.filter((product): product is PieceModelProduct => Boolean(product))
const draft: PieceModelStructure = {
...safeClone(restSource, {}),
products: normalizedProducts,
customFields: normalizedFields,
}
return normalizePieceStructureForSave(draft)
}
const serializeStructure = (structure?: PieceModelStructure | null): string => {
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
}
// --- Composable ---
export function usePieceStructureEditorLogic(deps: Deps) {
const { props, emit } = deps
const { productTypes, loadProductTypes } = useProductTypes()
// --- State ---
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
// --- Product types ---
const productTypeOptions = computed(() => productTypes.value ?? [])
const productTypeMap = computed(() => {
const map = new Map<string, any>()
productTypeOptions.value.forEach((type: any) => {
if (type?.id) {
map.set(type.id, type)
}
})
return map
})
const formatProductTypeOption = (type: any) => {
if (!type) {
return ''
}
const parts: string[] = []
if (type.code) {
parts.push(type.code)
}
if (type.name) {
parts.push(type.name)
}
return parts.length ? parts.join(' • ') : type.id || ''
}
const updateProductTypeMetadata = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
}
const handleProductTypeSelect = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
if (option?.code) {
product.familyCode = option.code
}
}
// --- CRUD ---
const createEmptyProduct = (): EditorProduct => ({
uid: createUid('product'),
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
const addProduct = () => {
products.value.push(createEmptyProduct())
}
const removeProduct = (index: number) => {
products.value = products.value.filter((_, idx) => idx !== index)
}
const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid('field'),
name: '',
type: 'text',
required: false,
optionsText: '',
orderIndex,
})
const addField = () => {
const next = fields.value.slice()
next.push(createEmptyField(next.length))
fields.value = applyOrderIndex(next)
}
const removeField = (index: number) => {
const next = fields.value.filter((_, i) => i !== index)
fields.value = applyOrderIndex(next)
}
// --- Drag & drop ---
const dragState = reactive({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const resetDragState = () => {
dragState.draggingIndex = null
dragState.dropTargetIndex = null
}
const reorderFields = (from: number, to: number) => {
if (from === to) {
resetDragState()
return
}
const list = fields.value.slice()
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
if (!moved) {
resetDragState()
return
}
list.splice(to, 0, moved)
fields.value = applyOrderIndex(list)
resetDragState()
}
const onDragStart = (index: number, event: DragEvent) => {
dragState.draggingIndex = index
dragState.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (dragState.draggingIndex === null) {
return
}
dragState.dropTargetIndex = index
}
const onDrop = (index: number) => {
if (dragState.draggingIndex === null) {
resetDragState()
return
}
reorderFields(dragState.draggingIndex, index)
}
const onDragEnd = () => {
resetDragState()
}
const reorderClass = (index: number) => {
if (dragState.draggingIndex === index) {
return 'border-dashed border-primary bg-primary/5'
}
if (
dragState.draggingIndex !== null
&& dragState.dropTargetIndex === index
&& dragState.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/10'
}
return ''
}
// --- Emit ---
let lastEmitted = serializeStructure(props.modelValue)
const emitUpdate = () => {
const payload = buildPayload(fields.value, products.value, restState.value)
const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) {
lastEmitted = serialized
emit('update:modelValue', payload)
}
}
// --- Watchers ---
watch(fields, emitUpdate, { deep: true })
watch(products, emitUpdate, { deep: true })
watch(productTypeOptions, () => {
products.value.forEach(product => updateProductTypeMetadata(product))
})
watch(
() => props.modelValue,
(value) => {
const incomingSerialized = serializeStructure(value)
if (incomingSerialized === lastEmitted) {
return
}
restState.value = extractRest(value)
fields.value = hydrateFields(value)
products.value = hydrateProducts(value)
products.value.forEach(product => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized
},
{ deep: true },
)
// --- Lifecycle ---
onMounted(async () => {
if (!productTypeOptions.value.length) {
await loadProductTypes()
}
products.value.forEach(product => updateProductTypeMetadata(product))
})
return {
fields,
products,
productTypeOptions,
formatProductTypeOption,
handleProductTypeSelect,
addProduct,
removeProduct,
addField,
removeField,
reorderClass,
onDragStart,
onDragEnter,
onDrop,
onDragEnd,
}
}

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
@@ -10,6 +10,7 @@ export interface Piece {
id: string id: string
name: string name: string
reference?: string | null reference?: string | null
referenceAuto?: string | null
description?: string | null description?: string | null
typePieceId?: string | null typePieceId?: string | null
typePiece?: { id: string; name?: string } | null typePiece?: { id: string; name?: string } | null
@@ -43,6 +44,7 @@ interface LoadPiecesOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
typeName?: string typeName?: string
typePieceId?: string
force?: boolean force?: boolean
} }
@@ -119,17 +121,20 @@ export function usePieces() {
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
typeName, typeName,
typePieceId,
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && !typeName && page === 1) { // Only use cache for unfiltered full-catalog loads
if (!force && loaded.value && !search && !typeName && !typePieceId && page === 1) {
return { return {
success: true, success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage }, data: { items: pieces.value, total: total.value, page, itemsPerPage },
} }
} }
if (loading.value) { // For filtered queries, don't block on global loading state
if (!typePieceId && loading.value) {
return { return {
success: true, success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage }, data: { items: pieces.value, total: total.value, page, itemsPerPage },
@@ -138,33 +143,42 @@ export function usePieces() {
loading.value = true loading.value = true
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage)) params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page)) params.set('page', String(page))
if (search && search.trim()) { if (search && search.trim()) {
params.set('name', search.trim()) params.set('search', search.trim())
} }
if (typeName && typeName.trim()) { if (typeName && typeName.trim()) {
params.set('typePiece.name', typeName.trim()) params.set('typePiece.name', typeName.trim())
} }
if (typePieceId) {
params.set('typePiece', typePieceId)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/pieces?${params.toString()}`) const result = await get(`/pieces?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
pieces.value = enrichedItems const resultTotal = extractTotal(result.data, items.length)
total.value = extractTotal(result.data, items.length)
loaded.value = true // Only update global cache for unfiltered queries
if (!typePieceId) {
pieces.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return { return {
success: true, success: true,
data: { data: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: resultTotal,
page, page,
itemsPerPage, itemsPerPage,
}, },
@@ -182,7 +196,8 @@ export function usePieces() {
const createPiece = async (pieceData: Partial<Piece>): Promise<PieceSingleResult> => { const createPiece = async (pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = pieceData as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
const result = await post('/pieces', normalizedPayload) const result = await post('/pieces', normalizedPayload)
if (result.success && result.data) { if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data as Piece) const enriched = await withResolvedConstructeurs(result.data as Piece)
@@ -209,7 +224,8 @@ export function usePieces() {
const updatePieceData = async (id: string, pieceData: Partial<Piece>): Promise<PieceSingleResult> => { const updatePieceData = async (id: string, pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = pieceData as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
const result = await patch(`/pieces/${id}`, normalizedPayload) const result = await patch(`/pieces/${id}`, normalizedPayload)
if (result.success && result.data) { if (result.success && result.data) {
const updated = await withResolvedConstructeurs(result.data as Piece) const updated = await withResolvedConstructeurs(result.data as Piece)

View File

@@ -2,7 +2,7 @@ import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
@@ -41,6 +41,7 @@ interface LoadProductsOptions {
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
typeName?: string typeName?: string
typeProductId?: string
force?: boolean force?: boolean
} }
@@ -118,17 +119,18 @@ export function useProducts() {
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
typeName, typeName,
typeProductId,
force = false, force = false,
} = options } = options
if (!force && loaded.value && !search && !typeName && page === 1) { if (!force && loaded.value && !search && !typeName && !typeProductId && page === 1) {
return { return {
success: true, success: true,
data: { items: products.value, total: total.value, page, itemsPerPage }, data: { items: products.value, total: total.value, page, itemsPerPage },
} }
} }
if (loading.value) { if (!typeProductId && loading.value) {
return { return {
success: true, success: true,
data: { items: products.value, total: total.value, page, itemsPerPage }, data: { items: products.value, total: total.value, page, itemsPerPage },
@@ -143,27 +145,36 @@ export function useProducts() {
params.set('page', String(page)) params.set('page', String(page))
if (search && search.trim()) { if (search && search.trim()) {
params.set('name', search.trim()) params.set('search', search.trim())
} }
if (typeName && typeName.trim()) { if (typeName && typeName.trim()) {
params.set('typeProduct.name', typeName.trim()) params.set('typeProduct.name', typeName.trim())
} }
if (typeProductId) {
params.set('typeProduct', typeProductId)
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/products?${params.toString()}`) const result = await get(`/products?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
products.value = enrichedItems const resultTotal = extractTotal(result.data, items.length)
total.value = extractTotal(result.data, items.length)
loaded.value = true if (!typeProductId) {
products.value = enrichedItems
total.value = resultTotal
loaded.value = true
}
return { return {
success: true, success: true,
data: { data: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: resultTotal,
page, page,
itemsPerPage, itemsPerPage,
}, },
@@ -185,7 +196,8 @@ export function useProducts() {
} }
const createProduct = async (payload: Partial<Product>): Promise<ProductSingleResult> => { const createProduct = async (payload: Partial<Product>): Promise<ProductSingleResult> => {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = payload as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
@@ -214,7 +226,8 @@ export function useProducts() {
} }
const updateProduct = async (id: string, payload: Partial<Product>): Promise<ProductSingleResult> => { const updateProduct = async (id: string, payload: Partial<Product>): Promise<ProductSingleResult> => {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload)) const { constructeurIds, constructeurs, constructeurId, constructeur, ...cleanPayload } = payload as any
const normalizedPayload = normalizeRelationIds(cleanPayload)
loading.value = true loading.value = true
error.value = null error.value = null
try { try {

View File

@@ -10,6 +10,7 @@ import { canPreviewDocument } from '~/utils/documentPreview'
type SiteForm = { type SiteForm = {
name: string name: string
color: string
contactName: string contactName: string
contactPhone: string contactPhone: string
contactAddress: string contactAddress: string
@@ -31,6 +32,7 @@ type SiteDocument = {
type SiteWithDocuments = { type SiteWithDocuments = {
id: string id: string
name?: string name?: string
color?: string
contactName?: string contactName?: string
contactPhone?: string contactPhone?: string
contactAddress?: string contactAddress?: string
@@ -54,6 +56,7 @@ export function useSiteManagement() {
const newSite = reactive<SiteForm>({ const newSite = reactive<SiteForm>({
name: '', name: '',
color: '',
contactName: '', contactName: '',
contactPhone: '', contactPhone: '',
contactAddress: '', contactAddress: '',
@@ -63,6 +66,7 @@ export function useSiteManagement() {
const editSiteForm = reactive<SiteForm>({ const editSiteForm = reactive<SiteForm>({
name: '', name: '',
color: '',
contactName: '', contactName: '',
contactPhone: '', contactPhone: '',
contactAddress: '', contactAddress: '',
@@ -81,6 +85,7 @@ export function useSiteManagement() {
const resetNewSite = () => { const resetNewSite = () => {
newSite.name = '' newSite.name = ''
newSite.color = ''
newSite.contactName = '' newSite.contactName = ''
newSite.contactPhone = '' newSite.contactPhone = ''
newSite.contactAddress = '' newSite.contactAddress = ''
@@ -101,6 +106,7 @@ export function useSiteManagement() {
const handleCreateSite = async () => { const handleCreateSite = async () => {
const result = await createSite({ const result = await createSite({
name: newSite.name, name: newSite.name,
color: newSite.color,
contactName: newSite.contactName, contactName: newSite.contactName,
contactPhone: newSite.contactPhone, contactPhone: newSite.contactPhone,
contactAddress: newSite.contactAddress, contactAddress: newSite.contactAddress,
@@ -116,6 +122,7 @@ export function useSiteManagement() {
const editSite = (site: SiteWithDocuments) => { const editSite = (site: SiteWithDocuments) => {
siteBeingEdited.value = site siteBeingEdited.value = site
editSiteForm.name = site.name || '' editSiteForm.name = site.name || ''
editSiteForm.color = site.color || ''
editSiteForm.contactName = site.contactName || '' editSiteForm.contactName = site.contactName || ''
editSiteForm.contactPhone = site.contactPhone || '' editSiteForm.contactPhone = site.contactPhone || ''
editSiteForm.contactAddress = site.contactAddress || '' editSiteForm.contactAddress = site.contactAddress || ''
@@ -148,6 +155,7 @@ export function useSiteManagement() {
const baseUpdate = { const baseUpdate = {
name: editSiteForm.name, name: editSiteForm.name,
color: editSiteForm.color,
contactName: editSiteForm.contactName, contactName: editSiteForm.contactName,
contactPhone: editSiteForm.contactPhone, contactPhone: editSiteForm.contactPhone,
contactAddress: editSiteForm.contactAddress, contactAddress: editSiteForm.contactAddress,

View File

@@ -6,6 +6,7 @@ import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Site { export interface Site {
id: string id: string
name?: string name?: string
color?: string
contactName?: string contactName?: string
contactPhone?: string contactPhone?: string
contactAddress?: string contactAddress?: string

View File

@@ -0,0 +1,380 @@
import { computed, ref, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { extractCollection } from '~/shared/utils/apiHelpers'
import {
componentOptionDescription,
componentOptionLabel,
describePieceRequirement as _describePieceRequirement,
describeProductRequirement as _describeProductRequirement,
pieceOptionDescription,
pieceOptionLabel,
productOptionDescription,
productOptionLabel,
} from '~/shared/utils/structureAssignmentLabels'
import type {
ComponentOption,
PieceOption,
ProductOption,
StructureAssignmentNode,
StructurePieceAssignment,
StructureProductAssignment,
} from '~/shared/utils/structureAssignmentLabels'
export type {
ComponentOption,
PieceOption,
ProductOption,
StructureAssignmentNode,
StructurePieceAssignment,
StructureProductAssignment,
} from '~/shared/utils/structureAssignmentLabels'
export interface StructureAssignmentFetchDeps {
assignment: StructureAssignmentNode
pieces: PieceOption[] | null
products: ProductOption[] | null
components: ComponentOption[] | null
isRoot: () => boolean
pieceTypeLabelMap: Record<string, string>
productTypeLabelMap: Record<string, string>
componentTypeLabelMap: Record<string, string>
}
export function useStructureAssignmentFetch(deps: StructureAssignmentFetchDeps) {
const { get } = useApi()
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({})
const productOptionsByPath = ref<Record<string, ProductOption[]>>({})
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({})
const pieceLoadingByPath = ref<Record<string, boolean>>({})
const productLoadingByPath = ref<Record<string, boolean>>({})
const componentLoadingByPath = ref<Record<string, boolean>>({})
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
target[key] = value
}
const typeIri = (id: string) => `/api/model_types/${id}`
const primedPiecePaths = new Set<string>()
const primedProductPaths = new Set<string>()
const primedComponentPaths = new Set<string>()
// --- Component options ---
const componentOptions = computed(() => {
if (deps.isRoot()) {
return []
}
const cached = componentOptionsByPath.value[deps.assignment.path]
if (cached) {
return cached
}
const definition = deps.assignment.definition || {}
const requiredTypeId =
definition.typeComposantId || definition.modelId || null
const requiredFamilyCode = definition.familyCode || null
return (deps.components || []).filter((component) => {
if (!component || typeof component !== 'object') {
return false
}
if (requiredTypeId) {
return component.typeComposantId === requiredTypeId
}
if (requiredFamilyCode) {
return (
component.typeComposant?.code === requiredFamilyCode
|| component.typeComposantId === requiredFamilyCode
)
}
return true
})
})
const fetchComponentOptions = async (term = '') => {
if (deps.isRoot()) {
return
}
const key = deps.assignment.path
if (componentLoadingByPath.value[key]) {
return
}
const definition = deps.assignment.definition || {}
const requiredTypeId =
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null
const params = new URLSearchParams()
params.set('itemsPerPage', '200')
if (term.trim()) {
params.set('search', term.trim())
}
if (requiredTypeId) {
params.set('typeComposant', typeIri(requiredTypeId))
}
setLoading(componentLoadingByPath.value, key, true)
try {
const result = await get(`/composants?${params.toString()}`)
if (result.success) {
componentOptionsByPath.value[key] = extractCollection(result.data)
}
}
finally {
setLoading(componentLoadingByPath.value, key, false)
}
}
// --- Piece options ---
const getPieceOptions = (assignment: StructurePieceAssignment) => {
const cached = pieceOptionsByPath.value[assignment.path]
if (cached) {
return cached
}
const definition = assignment.definition
const requiredTypeId =
definition.typePieceId
|| definition.typePiece?.id
|| definition.familyCode
|| null
return (deps.pieces || []).filter((piece) => {
if (!piece || typeof piece !== 'object') {
return false
}
if (!requiredTypeId) {
return true
}
if (definition.typePieceId || definition.typePiece?.id) {
return (
piece.typePieceId === requiredTypeId
|| piece.typePiece?.id === requiredTypeId
)
}
if (definition.familyCode) {
return (
piece.typePiece?.code === requiredTypeId
|| piece.typePieceId === requiredTypeId
)
}
return false
})
}
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
const key = assignment.path
if (pieceLoadingByPath.value[key]) {
return
}
const definition = assignment.definition || {}
const requiredTypeId =
definition.typePieceId || definition.typePiece?.id || null
const params = new URLSearchParams()
params.set('itemsPerPage', '200')
if (term.trim()) {
params.set('search', term.trim())
}
if (requiredTypeId) {
params.set('typePiece', typeIri(requiredTypeId))
}
setLoading(pieceLoadingByPath.value, key, true)
try {
const result = await get(`/pieces?${params.toString()}`)
if (result.success) {
pieceOptionsByPath.value[key] = extractCollection(result.data)
}
}
finally {
setLoading(pieceLoadingByPath.value, key, false)
}
}
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
const options = getPieceOptions(assignment)
return _describePieceRequirement(assignment, options, deps.pieceTypeLabelMap)
}
// --- Product options ---
const getProductOptions = (assignment: StructureProductAssignment) => {
const cached = productOptionsByPath.value[assignment.path]
if (cached) {
return cached
}
const definition = assignment.definition
const requiredTypeId =
definition.typeProductId
|| definition.typeProduct?.id
|| definition.familyCode
|| null
return (deps.products || []).filter((product) => {
if (!product || typeof product !== 'object') {
return false
}
if (!requiredTypeId) {
return true
}
if (definition.typeProductId || definition.typeProduct?.id) {
return (
product.typeProductId === requiredTypeId
|| product.typeProduct?.id === requiredTypeId
)
}
if (definition.familyCode) {
return (
product.typeProduct?.code === requiredTypeId
|| product.typeProductId === requiredTypeId
)
}
return false
})
}
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
const key = assignment.path
if (productLoadingByPath.value[key]) {
return
}
const definition = assignment.definition || {}
const requiredTypeId =
definition.typeProductId || definition.typeProduct?.id || null
const params = new URLSearchParams()
params.set('itemsPerPage', '200')
if (term.trim()) {
params.set('search', term.trim())
}
if (requiredTypeId) {
params.set('typeProduct', typeIri(requiredTypeId))
}
setLoading(productLoadingByPath.value, key, true)
try {
const result = await get(`/products?${params.toString()}`)
if (result.success) {
productOptionsByPath.value[key] = extractCollection(result.data)
}
}
finally {
setLoading(productLoadingByPath.value, key, false)
}
}
const describeProductRequirement = (assignment: StructureProductAssignment) => {
const options = getProductOptions(assignment)
return _describeProductRequirement(assignment, options, deps.productTypeLabelMap)
}
// --- Watchers ---
watch(
componentOptions,
(options) => {
if (deps.isRoot()) {
return
}
// Only clear if we have loaded options (cache or catalog); skip when options are empty
// because the fetch may not have completed yet.
if (!options.length) {
return
}
const hasMatch = options.some(
(component) => component.id === deps.assignment.selectedComponentId,
)
if (!hasMatch) {
deps.assignment.selectedComponentId = ''
}
},
{ immediate: true },
)
watch(
() => [deps.pieces, deps.assignment.pieces],
() => {
for (const pieceAssignment of deps.assignment.pieces) {
const hasCachedOptions = !!pieceOptionsByPath.value[pieceAssignment.path]
// Only clear selections when we have loaded options (cached or from catalog).
// When no cache exists, a fetch is about to fire — clearing now would lose
// user input before the real option list arrives.
if (hasCachedOptions) {
const options = getPieceOptions(pieceAssignment)
if (
pieceAssignment.selectedPieceId
&& !options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
) {
pieceAssignment.selectedPieceId = ''
}
}
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
primedPiecePaths.add(pieceAssignment.path)
fetchPieceOptions(pieceAssignment).catch(() => {})
}
}
},
{ deep: true, immediate: true },
)
watch(
() => [deps.products, deps.assignment.products],
() => {
for (const productAssignment of deps.assignment.products) {
const hasCachedOptions = !!productOptionsByPath.value[productAssignment.path]
if (hasCachedOptions) {
const options = getProductOptions(productAssignment)
if (
productAssignment.selectedProductId
&& !options.some((product) => product.id === productAssignment.selectedProductId)
) {
productAssignment.selectedProductId = ''
}
}
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
primedProductPaths.add(productAssignment.path)
fetchProductOptions(productAssignment).catch(() => {})
}
}
},
{ deep: true, immediate: true },
)
watch(
() => deps.assignment.definition,
() => {
if (deps.isRoot()) {
return
}
const key = deps.assignment.path
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
primedComponentPaths.add(key)
fetchComponentOptions().catch(() => {})
}
},
{ immediate: true },
)
return {
pieceLoadingByPath,
productLoadingByPath,
componentLoadingByPath,
componentOptions,
componentOptionLabel,
componentOptionDescription,
fetchComponentOptions,
getPieceOptions,
pieceOptionLabel,
pieceOptionDescription,
fetchPieceOptions,
describePieceRequirement,
getProductOptions,
productOptionLabel,
productOptionDescription,
fetchProductOptions,
describeProductRequirement,
}
}

View File

@@ -0,0 +1,161 @@
import type { EditableStructureNode } from '~/composables/useStructureNodeLogic'
export interface StructureNodeCrudDeps {
node: EditableStructureNode
canManageSubcomponents: () => boolean
}
export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
// --- Helpers ---
const ensureArray = (key: 'customFields' | 'pieces' | 'products' | 'subcomponents') => {
if (!Array.isArray((props.node as any)[key])) {
if (key === 'subcomponents') {
props.node.subcomponents = []
} else if (key === 'products') {
props.node.products = []
} else {
(props.node as any)[key] = []
}
}
}
// --- Custom field reindex ---
const reindexCustomFields = () => {
if (!Array.isArray(props.node.customFields)) {
return
}
props.node.customFields.forEach((field: any, index: number) => {
if (!field || typeof field !== 'object') {
return
}
field.orderIndex = index
})
}
// --- Drag reorder ---
const customFieldDrag = useDragReorder(
() => props.node.customFields,
{ onReorder: reindexCustomFields },
)
const pieceDrag = useDragReorder(() => props.node.pieces)
const productDrag = useDragReorder(() => props.node.products)
const subcomponentDrag = useDragReorder(
() => props.node.subcomponents,
{ draggingClass: 'ring-2 ring-primary', dropTargetClass: 'ring-2 ring-primary/70' },
)
// --- CRUD functions ---
const addCustomField = () => {
ensureArray('customFields')
const fields = props.node.customFields!
const nextIndex = fields.length
fields.push({
name: '',
type: 'text',
required: false,
optionsText: '',
options: [],
orderIndex: nextIndex,
})
reindexCustomFields()
}
const removeCustomField = (index: number) => {
if (!Array.isArray(props.node.customFields)) return
props.node.customFields.splice(index, 1)
reindexCustomFields()
}
const addPiece = () => {
ensureArray('pieces')
props.node.pieces!.push({
typePieceId: '',
typePieceLabel: '',
reference: '',
familyCode: '',
role: '',
quantity: 1,
})
}
const removePiece = (index: number) => {
if (!Array.isArray(props.node.pieces)) return
props.node.pieces.splice(index, 1)
}
const addProduct = () => {
ensureArray('products')
props.node.products!.push({
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
}
const removeProduct = (index: number) => {
if (!Array.isArray(props.node.products)) return
props.node.products.splice(index, 1)
}
const addSubComponent = () => {
if (!props.canManageSubcomponents()) {
return
}
ensureArray('subcomponents')
props.node.subcomponents.push({
typeComposantId: '',
typeComposantLabel: '',
modelId: '',
familyCode: '',
alias: '',
subcomponents: [],
})
}
const removeSubComponent = (index: number) => {
if (!Array.isArray(props.node.subcomponents)) return
props.node.subcomponents.splice(index, 1)
}
return {
// Helpers exposed for watchers
reindexCustomFields,
// CRUD
addCustomField,
removeCustomField,
addPiece,
removePiece,
addProduct,
removeProduct,
addSubComponent,
removeSubComponent,
// Drag reorder — custom fields
onCustomFieldDragStart: customFieldDrag.onDragStart,
onCustomFieldDragEnter: customFieldDrag.onDragEnter,
onCustomFieldDrop: customFieldDrag.onDrop,
onCustomFieldDragEnd: customFieldDrag.onDragEnd,
customFieldReorderClass: customFieldDrag.reorderClass,
// Drag reorder — pieces
onPieceDragStart: pieceDrag.onDragStart,
onPieceDragEnter: pieceDrag.onDragEnter,
onPieceDragOver: pieceDrag.onDragOver,
onPieceDrop: pieceDrag.onDrop,
onPieceDragEnd: pieceDrag.onDragEnd,
pieceReorderClass: pieceDrag.reorderClass,
// Drag reorder — products
onProductDragStart: productDrag.onDragStart,
onProductDragEnter: productDrag.onDragEnter,
onProductDragOver: productDrag.onDragOver,
onProductDrop: productDrag.onDrop,
onProductDragEnd: productDrag.onDragEnd,
productReorderClass: productDrag.reorderClass,
// Drag reorder — subcomponents
onSubcomponentDragStart: subcomponentDrag.onDragStart,
onSubcomponentDragEnter: subcomponentDrag.onDragEnter,
onSubcomponentDragOver: subcomponentDrag.onDragOver,
onSubcomponentDrop: subcomponentDrag.onDrop,
onSubcomponentDragEnd: subcomponentDrag.onDragEnd,
subcomponentReorderClass: subcomponentDrag.reorderClass,
}
}

View File

@@ -0,0 +1,453 @@
import { computed, watch } from 'vue'
import type { ComponentModelPiece, ComponentModelProduct, ComponentModelStructureNode } from '~/shared/types/inventory'
import { useStructureNodeCrud } from '~/composables/useStructureNodeCrud'
export type ModelTypeOption = {
id: string
name: string
code?: string | null
}
export type EditableStructureNode = ComponentModelStructureNode & {
customFields?: any[]
pieces?: ComponentModelPiece[]
products?: ComponentModelProduct[]
}
export interface StructureNodeLogicDeps {
node: EditableStructureNode
depth: number
componentTypes: ModelTypeOption[]
pieceTypes: ModelTypeOption[]
productTypes: ModelTypeOption[]
isRoot: boolean
lockType: boolean
lockedTypeLabel: string
allowSubcomponents: boolean
maxSubcomponentDepth: number
isLocked: boolean
}
export function useStructureNodeLogic(props: StructureNodeLogicDeps) {
// --- Computed props ---
const isLocked = computed(() => props.isLocked === true)
const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? [])
const productTypes = computed(() => props.productTypes ?? [])
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
const maxSubcomponentDepth = computed(() =>
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
)
const currentDepth = computed(() => Math.max(0, props.depth ?? 0))
const canManageSubcomponents = computed(
() => allowSubcomponents.value && currentDepth.value < maxSubcomponentDepth.value,
)
const childAllowSubcomponents = computed(
() => allowSubcomponents.value && currentDepth.value + 1 < maxSubcomponentDepth.value,
)
const hasSubcomponents = computed(
() => Array.isArray(props.node?.subcomponents) && props.node.subcomponents.length > 0,
)
const depthClasses = ['', 'ml-4', 'ml-8', 'ml-12', 'ml-16', 'ml-20']
const containerClass = computed(() => {
const level = currentDepth.value
const index = Math.min(level, depthClasses.length - 1)
return level === 0 ? 'space-y-4' : `${depthClasses[index]} space-y-4`
})
const headingClass = computed(() => (props.isRoot ? 'text-sm font-semibold' : 'text-xs font-semibold'))
// --- Type maps ---
const formatModelTypeOption = (type: ModelTypeOption | undefined | null) =>
type?.name ?? ''
const componentTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
componentTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
const componentTypeCodeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
componentTypes.value.forEach((type) => {
const code = typeof type?.code === 'string' ? type.code.trim() : ''
if (code) {
map.set(code, type)
}
})
return map
})
const pieceTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
pieceTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
const productTypeMap = computed(() => {
const map = new Map<string, ModelTypeOption>()
productTypes.value.forEach((type) => {
if (type && typeof type.id === 'string') {
map.set(type.id, type)
}
})
return map
})
// --- Label getters ---
const getComponentTypeLabel = (id?: string) => {
if (!id) return ''
return formatModelTypeOption(componentTypeMap.value.get(id))
}
const getPieceTypeLabel = (id?: string) => {
if (!id) return ''
return formatModelTypeOption(pieceTypeMap.value.get(id))
}
const formatComponentTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const formatPieceTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const formatProductTypeOption = (type: ModelTypeOption | undefined | null) =>
formatModelTypeOption(type)
const lockedTypeDisplay = computed(() => {
if (props.lockedTypeLabel) {
return props.lockedTypeLabel
}
return getComponentTypeLabel(props.node?.typeComposantId) || 'Famille non définie'
})
// --- Sync functions ---
const syncComponentType = (component: EditableStructureNode) => {
if (!component) {
return
}
if (props.isRoot) {
component.typeComposantId = ''
component.typeComposantLabel = ''
component.familyCode = ''
if (component.alias) {
component.alias = ''
}
return
}
const id = typeof component.typeComposantId === 'string'
? component.typeComposantId
: ''
if (!id) {
const code =
typeof component.familyCode === 'string' && component.familyCode
? component.familyCode
: ''
if (code) {
const codeMatch = componentTypeCodeMap.value.get(code)
if (codeMatch?.id) {
component.typeComposantId = codeMatch.id
component.typeComposantLabel = formatModelTypeOption(codeMatch)
component.familyCode = codeMatch.code ?? component.familyCode
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
component.alias = codeMatch.name || component.typeComposantLabel
}
return
}
}
component.typeComposantLabel = ''
component.familyCode = ''
return
}
const option = componentTypeMap.value.get(id)
if (!option) {
component.typeComposantLabel = ''
component.familyCode = ''
return
}
component.typeComposantLabel = formatModelTypeOption(option)
component.familyCode = option.code ?? component.familyCode
if (!component.alias || component.alias === '' || component.alias === lockedTypeDisplay.value) {
component.alias = option.name || component.typeComposantLabel
}
}
const updatePieceTypeLabel = (piece: ComponentModelPiece & Record<string, any>) => {
if (!piece) return
if (piece.typePieceId) {
const option = pieceTypeMap.value.get(piece.typePieceId)
if (option) {
piece.typePieceLabel = formatPieceTypeOption(option)
return
}
}
if (piece.typePieceLabel) {
const normalized = piece.typePieceLabel.trim().toLowerCase()
if (normalized) {
const match = pieceTypes.value.find((type) => {
const formatted = formatPieceTypeOption(type).toLowerCase()
const name = (type?.name ?? '').toLowerCase()
const code = (type?.code ?? '').toLowerCase()
return formatted === normalized || name === normalized || (!!code && code === normalized)
})
if (match) {
piece.typePieceId = match.id
piece.typePieceLabel = formatPieceTypeOption(match)
return
}
}
}
}
const updateProductTypeLabel = (product: ComponentModelProduct & Record<string, any>) => {
if (!product) return
if (product.typeProductId) {
const option = productTypeMap.value.get(product.typeProductId)
if (option) {
product.typeProductLabel = formatProductTypeOption(option)
product.familyCode = option.code ?? product.familyCode ?? ''
return
}
}
if (product.typeProductLabel) {
const normalized = product.typeProductLabel.trim().toLowerCase()
if (normalized) {
const match = productTypes.value.find((type) => {
const formatted = formatProductTypeOption(type).toLowerCase()
const name = (type?.name ?? '').toLowerCase()
const code = (type?.code ?? '').toLowerCase()
return formatted === normalized || name === normalized || (!!code && code === normalized)
})
if (match) {
product.typeProductId = match.id
product.typeProductLabel = formatProductTypeOption(match)
product.familyCode = match.code ?? product.familyCode ?? ''
return
}
}
}
}
const syncPieceLabels = (pieces?: any[]) => {
if (!Array.isArray(pieces)) {
return
}
pieces.forEach((piece) => {
updatePieceTypeLabel(piece)
})
}
const syncProductLabels = (products?: any[]) => {
if (!Array.isArray(products)) {
return
}
products.forEach((product) => {
updateProductTypeLabel(product)
})
}
// --- Handler functions ---
const handleComponentTypeSelect = (component: any) => {
syncComponentType(component)
}
const handlePieceTypeSelect = (piece: ComponentModelPiece & Record<string, any>) => {
if (!piece) {
return
}
const id = typeof piece.typePieceId === 'string' ? piece.typePieceId : ''
if (!id) {
piece.typePieceLabel = ''
return
}
const option = pieceTypeMap.value.get(id)
if (!option) {
piece.typePieceId = ''
piece.typePieceLabel = ''
return
}
piece.typePieceLabel = formatPieceTypeOption(option)
}
const handleProductTypeSelect = (product: ComponentModelProduct & Record<string, any>) => {
if (!product) {
return
}
const id = typeof product.typeProductId === 'string' ? product.typeProductId : ''
if (!id) {
product.typeProductLabel = ''
return
}
const option = productTypeMap.value.get(id)
if (!option) {
product.typeProductId = ''
product.typeProductLabel = ''
return
}
product.typeProductLabel = formatProductTypeOption(option)
product.familyCode = option.code ?? product.familyCode ?? ''
}
// --- CRUD & Lock (delegated to useStructureNodeCrud) ---
const crud = useStructureNodeCrud({
node: props.node,
canManageSubcomponents: () => canManageSubcomponents.value,
})
// --- Watchers ---
watch(
canManageSubcomponents,
(allowed) => {
if (!allowed && Array.isArray(props.node.subcomponents) && props.node.subcomponents.length) {
props.node.subcomponents.splice(0, props.node.subcomponents.length)
}
},
{ immediate: true },
)
watch(componentTypes, () => {
syncComponentType(props.node)
}, { deep: true, immediate: true })
watch(
() => props.node.typeComposantId,
() => {
syncComponentType(props.node)
},
)
watch(pieceTypes, () => {
syncPieceLabels(props.node?.pieces)
}, { deep: true, immediate: true })
watch(
() => props.node.pieces,
(value) => {
syncPieceLabels(value)
},
{ deep: true },
)
watch(productTypes, () => {
syncProductLabels(props.node?.products)
}, { deep: true, immediate: true })
watch(
() => props.node.products,
(value) => {
syncProductLabels(value)
},
{ deep: true },
)
watch(
() => props.node.customFields,
(value) => {
if (!Array.isArray(value)) {
return
}
value.sort((a: any, b: any) => {
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
return left - right
})
crud.reindexCustomFields()
},
{ deep: true },
)
watch(
() => [props.lockedTypeLabel, props.lockType],
() => {
if (props.lockType && props.isRoot) {
const label = props.lockedTypeLabel || lockedTypeDisplay.value
props.node.typeComposantLabel = label
if (label && (!props.node.alias || props.node.alias === lockedTypeDisplay.value)) {
props.node.alias = label
}
if (props.node.typeComposantId) {
const option = componentTypeMap.value.get(props.node.typeComposantId)
props.node.familyCode = option?.code ?? props.node.familyCode
}
}
},
{ immediate: true },
)
return {
// Computed state
isLocked,
componentTypes,
pieceTypes,
productTypes,
canManageSubcomponents,
childAllowSubcomponents,
hasSubcomponents,
containerClass,
headingClass,
lockedTypeDisplay,
// Label getters & formatters
getComponentTypeLabel,
getPieceTypeLabel,
formatComponentTypeOption,
formatPieceTypeOption,
formatProductTypeOption,
// Handlers
handleComponentTypeSelect,
handlePieceTypeSelect,
handleProductTypeSelect,
// CRUD
addCustomField: crud.addCustomField,
removeCustomField: crud.removeCustomField,
addPiece: crud.addPiece,
removePiece: crud.removePiece,
addProduct: crud.addProduct,
removeProduct: crud.removeProduct,
addSubComponent: crud.addSubComponent,
removeSubComponent: crud.removeSubComponent,
// Drag reorder — custom fields
onCustomFieldDragStart: crud.onCustomFieldDragStart,
onCustomFieldDragEnter: crud.onCustomFieldDragEnter,
onCustomFieldDrop: crud.onCustomFieldDrop,
onCustomFieldDragEnd: crud.onCustomFieldDragEnd,
customFieldReorderClass: crud.customFieldReorderClass,
// Drag reorder — pieces
onPieceDragStart: crud.onPieceDragStart,
onPieceDragEnter: crud.onPieceDragEnter,
onPieceDragOver: crud.onPieceDragOver,
onPieceDrop: crud.onPieceDrop,
onPieceDragEnd: crud.onPieceDragEnd,
pieceReorderClass: crud.pieceReorderClass,
// Drag reorder — products
onProductDragStart: crud.onProductDragStart,
onProductDragEnter: crud.onProductDragEnter,
onProductDragOver: crud.onProductDragOver,
onProductDrop: crud.onProductDrop,
onProductDragEnd: crud.onProductDragEnd,
productReorderClass: crud.productReorderClass,
// Drag reorder — subcomponents
onSubcomponentDragStart: crud.onSubcomponentDragStart,
onSubcomponentDragEnter: crud.onSubcomponentDragEnter,
onSubcomponentDragOver: crud.onSubcomponentDragOver,
onSubcomponentDrop: crud.onSubcomponentDrop,
onSubcomponentDragEnd: crud.onSubcomponentDragEnd,
subcomponentReorderClass: crud.subcomponentReorderClass,
}
}

View File

@@ -76,6 +76,7 @@ export function useToast() {
const clearAll = (): void => { const clearAll = (): void => {
toasts.value = [] toasts.value = []
recentMessages.clear()
} }
return { return {

View File

@@ -1,213 +1,694 @@
<template> <template>
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8"> <main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
<header class="space-y-2"> <header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">Changelog</h1> <h1 class="text-3xl font-bold text-base-content">Changelog</h1>
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/70">
Historique des modifications et nouvelles fonctionnalités de l'application. Historique des modifications et nouvelles fonctionnalités de
</p> l'application.
</header> </p>
</header>
<section <section
v-for="release in releases" v-for="release in releases"
:key="release.version" :key="release.version"
class="card border border-base-200 bg-base-100 shadow-sm" class="card border border-base-200 bg-base-100 shadow-sm"
> >
<div class="card-body space-y-3"> <div class="card-body space-y-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-base-content"> <h2 class="text-xl font-bold text-base-content">
{{ release.version }} {{ release.version }}
</h2> </h2>
<span class="badge badge-ghost text-xs">{{ release.date }}</span> <span class="badge badge-ghost text-xs">{{
</div> release.date
}}</span>
</div>
<ul class="space-y-2"> <ul class="space-y-2">
<li <li
v-for="(item, i) in release.changes" v-for="(item, i) in release.changes"
:key="i" :key="i"
class="flex items-start gap-2 text-sm text-base-content/80" class="flex items-start gap-2 text-sm text-base-content/80"
> >
<span <span
class="badge badge-sm mt-0.5 shrink-0" class="badge badge-sm mt-0.5 shrink-0"
:class="badgeClass(item.type)" :class="badgeClass(item.type)"
> >
{{ item.type }} {{ item.type }}
</span> </span>
<span>{{ item.text }}</span> <span>{{ item.text }}</span>
</li> </li>
</ul> </ul>
</div> </div>
</section> </section>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '#imports' import { useHead } from "#imports";
useHead({ title: 'Changelog' }) useHead({ title: "Changelog" });
type ChangeType = 'feat' | 'fix' | 'perf' | 'chore' type ChangeType = "feat" | "fix" | "perf" | "chore";
interface Change { interface Change {
type: ChangeType type: ChangeType;
text: string text: string;
} }
interface Release { interface Release {
version: string version: string;
date: string date: string;
changes: Change[] changes: Change[];
} }
const badgeClass = (type: ChangeType) => { const badgeClass = (type: ChangeType) => {
const map: Record<ChangeType, string> = { const map: Record<ChangeType, string> = {
feat: 'badge-primary', feat: "badge-primary",
fix: 'badge-error', fix: "badge-error",
perf: 'badge-warning', perf: "badge-warning",
chore: 'badge-ghost', chore: "badge-ghost",
} };
return map[type] ?? 'badge-ghost' return map[type] ?? "badge-ghost";
} };
const releases: Release[] = [ const releases: Release[] = [
{ {
version: 'v1.8.0', version: "v1.9.5",
date: '2026-03-03', date: "2026-03-31",
changes: [ changes: [
{ type: 'feat', text: 'Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers' }, {
{ type: 'feat', text: 'Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d\'éléments par page' }, type: "feat",
{ type: 'feat', text: 'Compression PDF automatique à l\'upload via Ghostscript, avec commande pour compresser les PDFs existants' }, text: "Référence automatique des pièces et composants : génération d'une référence technique à partir d'une formule configurable sur la catégorie, recalculée automatiquement à chaque modification des champs personnalisés",
{ type: 'feat', text: 'Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol' }, },
{ type: 'feat', text: 'Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)' }, {
{ type: 'fix', text: 'Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l\'API' }, type: "feat",
{ type: 'fix', text: 'Édition de squelettes machines : correction du conflit UniqueEntity et de l\'interférence du désérialiseur' }, text: "Formula builder interactif : sélection des champs disponibles par clic (chips) avec insertion à la position du curseur, aperçu live avec valeurs d'exemple, et calcul automatique des champs requis",
{ type: 'fix', text: 'Sites : ajout de l\'opération PATCH et correction de la migration de contrainte' }, },
{ type: 'chore', text: 'Réorganisation de la navbar avec nouvelles icônes Lucide' }, {
], type: "feat",
}, text: "Versioning des entités : numéro de version incrémenté automatiquement à chaque modification, avec historique des versions et possibilité de restaurer une version antérieure",
{ },
version: 'v1.7.0', {
date: '2026-03-02', type: "feat",
changes: [ text: "Bouton de sauvegarde unique sur la fiche machine : remplacement des sauvegardes automatiques par un bouton explicite, avec affichage des versions sur les liens composants/pièces/produits",
{ type: 'feat', text: 'Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires' }, },
{ type: 'feat', text: 'Page commentaires centralisée (/comments) avec filtres par statut, type d\'entité, pagination et liens cliquables vers les fiches' }, ],
{ type: 'feat', text: 'Badge notifications : compteur de commentaires ouverts sur l\'avatar utilisateur et dans le menu profil (polling 60s)' }, },
{ type: 'feat', text: 'Contrôle d\'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages' }, {
{ type: 'feat', text: 'Journal d\'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions' }, version: "v1.9.4",
{ type: 'feat', text: 'Commande app:init-profile-passwords pour l\'initialisation en masse des mots de passe et rôles' }, date: "2026-03-25",
{ type: 'fix', text: 'Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)' }, changes: [
{ type: 'fix', text: 'Recherche fournisseur : filtrage côté client au lieu d\'appels API debounce' }, {
{ type: 'fix', text: 'Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)' }, type: "feat",
{ type: 'fix', text: 'Correction de la création de squelettes machines : pagination, duplication, champs personnalisés' }, text: "Pages de consultation détaillées pour les pièces, composants et produits : vue lecture seule avec affichage propre des informations, fournisseurs, champs personnalisés et documents",
], },
}, {
{ type: "feat",
version: 'v1.6.1', text: "Bouton bascule Modifier / Voir détails sur les fiches pièces, composants et produits, identique au fonctionnement de la fiche machine",
date: '2026-02-12', },
changes: [ {
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' }, type: "feat",
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' }, text: "Bouton « Détails » sur les catalogues pièces, composants et produits pour accéder directement à la vue consultation",
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' }, },
], {
}, type: "feat",
{ text: "Masquage automatique des champs vides et de la section documents en mode consultation",
version: 'v1.6.0', },
date: '2026-02-12', {
changes: [ type: "feat",
{ type: 'feat', text: 'Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs' }, text: "Accès direct au mode édition via le paramètre ?edit=true dans l'URL",
{ type: 'feat', text: 'Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms' }, },
{ type: 'feat', text: 'Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée' }, ],
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' }, },
], {
}, version: "v1.9.2",
{ date: "2026-03-23",
version: 'v1.5.0', changes: [
date: '2026-02-11', {
changes: [ type: "feat",
{ type: 'feat', text: 'Page de journal d\'activité globale avec filtres par entité, par acteur et pagination serveur' }, text: "Serveur MCP (Model Context Protocol) : l'application expose désormais un serveur MCP permettant l'intégration avec des assistants IA — outils CRUD complets pour toutes les entités, recherche inventaire, historique, commentaires, champs personnalisés, documents, slots et structure machine",
{ type: 'feat', text: 'Suivi d\'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés' }, },
{ type: 'feat', text: 'Préservation de l\'état des listes dans l\'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente' }, {
{ type: 'feat', text: 'Boutons « Retour » sur toutes les pages de création et d\'édition utilisent désormais l\'historique du navigateur au lieu de liens fixes' }, type: "feat",
{ type: 'feat', text: 'Première lettre automatiquement en majuscule lors de la création de catégories et de composants' }, text: "Types de documents : classification des documents par type (Plan, Photo, Fiche technique, Notice, Certificat, Facture, Bon de commande, Autre) avec filtre dédié sur la page documents, sélection du type à l'upload et possibilité de modifier le type après upload",
{ type: 'feat', text: 'Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d\'édition)' }, },
{ type: 'feat', text: 'Application des couleurs de marque Malio sur l\'ensemble du thème (navbar, boutons, badges)' }, {
{ type: 'feat', text: 'Page changelog accessible depuis le footer' }, type: "feat",
{ type: 'fix', text: 'Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits' }, text: "Filtre sites multi-sélection sur le Parc Machines : remplacement du menu déroulant par des cases à cocher permettant de filtrer sur un ou plusieurs sites simultanément",
{ type: 'fix', text: 'Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents' }, },
{ type: 'fix', text: 'Correction de l\'affichage des champs personnalisés sur les pages d\'édition (condition de concurrence)' }, {
{ type: 'fix', text: 'Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production' }, type: "feat",
{ type: 'perf', text: 'Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement' }, text: "Tri alphabétique automatique des machines sur le Parc Machines",
{ type: 'perf', text: 'Réduction des appels API bloquants sur les pages d\'édition' }, },
], {
}, type: "feat",
{ text: "Recherche par nom OU référence sur les catalogues : la recherche dans les catalogues pièces, composants et produits cherche désormais dans le nom et la référence simultanément (extension Doctrine OR search)",
version: 'v1.4.0', },
date: '2026-02-04', {
changes: [ type: "feat",
{ type: 'perf', text: 'Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses' }, text: "Quantité sur les slots pièces : ajout d'un champ quantité éditable directement depuis la page d'édition d'un composant",
{ type: 'perf', text: 'Pages d\'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel' }, },
], {
}, type: "feat",
{ text: "Lien rapide vers la catégorie depuis la page d'édition d'un composant",
version: 'v1.3.0', },
date: '2026-01-28', {
changes: [ type: "feat",
{ type: 'feat', text: 'Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)' }, text: "Redirection vers la page d'édition après création d'un composant, d'une pièce ou d'un produit",
{ type: 'feat', text: 'Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants' }, },
{ type: 'feat', text: 'Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants' }, {
{ type: 'feat', text: 'Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)' }, type: "fix",
{ type: 'feat', text: 'Fusion des composables dupliqués : 3 composables d\'historique et 3 composables de types fusionnés en versions génériques' }, text: "Correction de la suppression de fournisseurs sur les pièces, composants et produits : la suppression est maintenant persistée correctement",
{ type: 'feat', text: 'Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l\'ensemble de l\'application' }, },
{ type: 'feat', text: 'Extraction de la navbar dans un composant AppNavbar dédié' }, {
{ type: 'feat', text: 'Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables' }, type: "fix",
{ type: 'perf', text: 'Optimisations API : helper extractCollection partagé, invalidation de cache ciblée' }, text: "Correction de la création de composants : les sélections de pièces, produits et sous-composants sont maintenant sauvegardées, et les slots squelette sont correctement initialisés",
{ type: 'chore', text: 'Migration des composables JavaScript vers TypeScript strict' }, },
{ type: 'chore', text: 'Activation de règles ESLint strictes et suppression de 19 console.log de débogage' }, {
], type: "fix",
}, text: "Correction de la perte de données lors de la sauvegarde d'une catégorie (champs personnalisés et structure)",
{ },
version: 'v1.2.0', {
date: '2026-01-21', type: "fix",
changes: [ text: "Correction de la suppression de composants depuis la fiche machine (utilisation du linkId au lieu du composantId)",
{ type: 'feat', text: 'Système de suivi d\'historique (audit) avec enregistrement automatique des modifications sur toutes les entités' }, },
{ type: 'feat', text: 'Interface dédiée à l\'historique sur les fiches produits, pièces et composants' }, {
{ type: 'feat', text: 'Modale d\'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d\'édition' }, type: "fix",
{ type: 'feat', text: 'Possibilité d\'ajouter des champs personnalisés en mode restreint sur les catégories' }, text: "Amélioration de l'envoi des fournisseurs en PATCH : le tableau est toujours envoyé pour éviter les pertes",
], },
}, {
{ type: "fix",
version: 'v1.1.1', text: "Filtrage serveur des options dans les sélecteurs de slots au lieu du filtrage client",
date: '2026-01-14', },
changes: [ {
{ type: 'feat', text: 'Compression automatique des fichiers PDF à l\'upload via qpdf, réduisant l\'espace de stockage' }, type: "fix",
{ type: 'chore', text: 'Ajout de qpdf dans l\'image Docker pour le support de la compression PDF' }, text: "Page d'édition pièce : rester sur la page après sauvegarde au lieu de rediriger",
], },
}, {
{ type: "fix",
version: 'v1.1.0', text: "Messages d'erreur 409 (conflit) : extraction du champ d'erreur pour un message compréhensible",
date: '2026-01-07', },
changes: [ {
{ type: 'fix', text: 'Recherche insensible à la casse sur l\'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)' }, type: "perf",
{ type: 'chore', text: 'Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement' }, text: "Suppression des chargements catalogue redondants sur la page d'édition composant",
{ type: 'chore', text: 'Mise à jour des fixtures avec les données courantes de la base' }, },
], ],
}, },
{ {
version: 'v1.0.0', version: "v1.9.1",
date: '2025-12-15', date: "2026-03-16",
changes: [ changes: [
{ type: 'feat', text: 'Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces' }, {
{ type: 'feat', text: 'Catalogues composants, pièces et produits avec recherche serveur, tri et pagination' }, type: "feat",
{ type: 'feat', text: 'Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner' }, text: "Normalisation JSON → tables relationnelles : les structures des composants (pièces, produits, sous-composants) et les squelettes des catégories sont désormais stockés dans des tables dédiées au lieu de colonnes JSON, améliorant la fiabilité et les performances des requêtes",
{ type: 'feat', text: 'Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux' }, },
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' }, {
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' }, type: "feat",
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' }, text: "Synchronisation des catégories (ModelType Sync) : la modification d'une catégorie (ajout/suppression de slots ou champs personnalisés) peut être propagée automatiquement à tous les éléments existants de cette catégorie, avec prévisualisation des changements avant application",
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' }, },
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' }, {
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' }, type: "feat",
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' }, text: "Sélection interactive des items dans les slots : sur la page d'édition d'un composant, il est maintenant possible de choisir directement la pièce, le produit ou le sous-composant assigné à chaque emplacement du squelette via des sélecteurs avec recherche",
{ type: 'chore', text: 'Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin' }, },
], {
}, type: "feat",
] text: "Endpoints PATCH pour les slots composant : modification de la quantité et de l'item sélectionné sur les slots pièce, produit et sous-composant",
},
{
type: "feat",
text: "Table de relation pièce ↔ produit (PieceProductSlot) avec versioning pour le suivi des modifications de structure",
},
{
type: "feat",
text: "Gestion des champs personnalisés sur les catégories : synchronisation automatique des définitions de champs (ajout, modification, suppression) lors de la sauvegarde d'une catégorie",
},
{
type: "feat",
text: "Suite de tests étendue : 219 tests couvrant les stratégies de synchronisation, le contrôleur de sync et les nouvelles entités",
},
{
type: "fix",
text: "Correction de l'affichage des sélections pré-existantes dans les slots : les pièces, produits et sous-composants déjà assignés sont maintenant correctement affichés à l'ouverture de la page d'édition (correction du cache catalogue)",
},
{
type: "fix",
text: "Fallback position/orderIndex sur index de tableau dans les stratégies de sync pour éviter les erreurs quand le champ est absent",
},
],
},
{
version: "v1.9.0",
date: "2026-03-09",
changes: [
{
type: "feat",
text: "Gestion des champs personnalisés sur les machines : ajout, modification et suppression de définitions de champs directement depuis la fiche machine",
},
{
type: "feat",
text: "Refonte UI globale : amélioration du styling, des layouts et du responsive sur l'ensemble des composants et pages",
},
{
type: "feat",
text: "Suite de tests API complète : 167 tests couvrant toutes les entités, la sécurité et les validations",
},
{
type: "feat",
text: "Endpoint /api/health pour le monitoring applicatif",
},
{
type: "fix",
text: "Sécurité renforcée : désactivation de la migration de session sur le firewall API, durcissement des accès documents et sessions",
},
{
type: "fix",
text: "Confirmation de suppression avec impact sur le catalogue produits (documents, liaisons machines en cascade)",
},
{
type: "fix",
text: "Correction du débordement des dropdowns dans les DataTable",
},
{
type: "perf",
text: "Refactoring massif du frontend : extraction de 15+ composables et composants partagés, réduction de la taille des fichiers",
},
{
type: "chore",
text: "Extraction de CuidEntityTrait et abstraction du subscriber d'audit côté backend",
},
{
type: "chore",
text: "Ajout de DAMA DoctrineTestBundle pour l'isolation des tests par transaction",
},
],
},
{
version: "v1.8.1",
date: "2026-03-05",
changes: [
{
type: "feat",
text: "Composant DataTable générique avec tri, recherche, pagination et filtres server-side toutes les pages catalogue migrées vers ce composant partagé",
},
{
type: "feat",
text: "Messages d'erreur humanisés : les erreurs backend sont traduites en messages compréhensibles pour l'utilisateur final",
},
{
type: "feat",
text: "Modal d'ajout d'entités aux machines : ajout direct de composants, pièces et produits depuis la fiche machine",
},
{
type: "feat",
text: "Filtres SearchFilter ipartial sur les noms de types de modèles et commentaires côté API",
},
{
type: "feat",
text: "Suppression du système TypeMachine (squelettes machines) : les champs personnalisés sont désormais liés directement à chaque machine",
},
{
type: "feat",
text: "Simplification de la création de machines : plus besoin de sélectionner un squelette, ajout direct des entités",
},
{
type: "fix",
text: "Suppression catalogue pièces/composants : confirmation avec liste des éléments supprimés en cascade (documents, liaisons machine, champs personnalisés) au lieu de bloquer la suppression",
},
{
type: "fix",
text: "Affichage des catégories sur les pages d'édition (produit, composant, pièce) : correction de « Catégorie inconnue » causée par un import obsolète dans ModelType",
},
{
type: "fix",
text: "Recherche insensible à la casse sur les commentaires et documents (partial → ipartial)",
},
{
type: "chore",
text: "Suppression des pages squelettes machines (/machine-skeleton, /type) et composants associés",
},
],
},
{
version: "v1.8.0",
date: "2026-03-03",
changes: [
{
type: "feat",
text: "Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers",
},
{
type: "feat",
text: "Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d'éléments par page",
},
{
type: "feat",
text: "Compression PDF automatique à l'upload via Ghostscript, avec commande pour compresser les PDFs existants",
},
{
type: "feat",
text: "Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol",
},
{
type: "feat",
text: "Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)",
},
{
type: "fix",
text: "Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l'API",
},
{
type: "fix",
text: "Édition de squelettes machines : correction du conflit UniqueEntity et de l'interférence du désérialiseur",
},
{
type: "fix",
text: "Sites : ajout de l'opération PATCH et correction de la migration de contrainte",
},
{
type: "chore",
text: "Réorganisation de la navbar avec nouvelles icônes Lucide",
},
],
},
{
version: "v1.7.0",
date: "2026-03-02",
changes: [
{
type: "feat",
text: "Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires",
},
{
type: "feat",
text: "Page commentaires centralisée (/comments) avec filtres par statut, type d'entité, pagination et liens cliquables vers les fiches",
},
{
type: "feat",
text: "Badge notifications : compteur de commentaires ouverts sur l'avatar utilisateur et dans le menu profil (polling 60s)",
},
{
type: "feat",
text: "Contrôle d'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages",
},
{
type: "feat",
text: "Journal d'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions",
},
{
type: "feat",
text: "Commande app:init-profile-passwords pour l'initialisation en masse des mots de passe et rôles",
},
{
type: "fix",
text: "Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)",
},
{
type: "fix",
text: "Recherche fournisseur : filtrage côté client au lieu d'appels API debounce",
},
{
type: "fix",
text: "Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)",
},
{
type: "fix",
text: "Correction de la création de squelettes machines : pagination, duplication, champs personnalisés",
},
],
},
{
version: "v1.6.1",
date: "2026-02-12",
changes: [
{
type: "feat",
text: "Suivi d'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents",
},
{
type: "feat",
text: "Traçabilité des conversions de catégories dans le journal d'activité (action « convert » avec direction, nombre et noms des éléments)",
},
{
type: "feat",
text: "Endpoint historique machine : GET /api/machines/{id}/history",
},
],
},
{
version: "v1.6.0",
date: "2026-02-12",
changes: [
{
type: "feat",
text: "Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs",
},
{
type: "feat",
text: "Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms",
},
{
type: "feat",
text: "Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée",
},
{
type: "chore",
text: "Passage php-cs-fixer sur l'ensemble des contrôleurs et entités du backend",
},
],
},
{
version: "v1.5.0",
date: "2026-02-11",
changes: [
{
type: "feat",
text: "Page de journal d'activité globale avec filtres par entité, par acteur et pagination serveur",
},
{
type: "feat",
text: "Suivi d'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés",
},
{
type: "feat",
text: "Préservation de l'état des listes dans l'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente",
},
{
type: "feat",
text: "Boutons « Retour » sur toutes les pages de création et d'édition utilisent désormais l'historique du navigateur au lieu de liens fixes",
},
{
type: "feat",
text: "Première lettre automatiquement en majuscule lors de la création de catégories et de composants",
},
{
type: "feat",
text: "Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d'édition)",
},
{
type: "feat",
text: "Application des couleurs de marque Malio sur l'ensemble du thème (navbar, boutons, badges)",
},
{
type: "feat",
text: "Page changelog accessible depuis le footer",
},
{
type: "fix",
text: "Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits",
},
{
type: "fix",
text: "Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents",
},
{
type: "fix",
text: "Correction de l'affichage des champs personnalisés sur les pages d'édition (condition de concurrence)",
},
{
type: "fix",
text: "Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production",
},
{
type: "perf",
text: "Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement",
},
{
type: "perf",
text: "Réduction des appels API bloquants sur les pages d'édition",
},
],
},
{
version: "v1.4.0",
date: "2026-02-04",
changes: [
{
type: "perf",
text: "Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses",
},
{
type: "perf",
text: "Pages d'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel",
},
],
},
{
version: "v1.3.0",
date: "2026-01-28",
changes: [
{
type: "feat",
text: "Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)",
},
{
type: "feat",
text: "Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants",
},
{
type: "feat",
text: "Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants",
},
{
type: "feat",
text: "Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)",
},
{
type: "feat",
text: "Fusion des composables dupliqués : 3 composables d'historique et 3 composables de types fusionnés en versions génériques",
},
{
type: "feat",
text: "Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l'ensemble de l'application",
},
{
type: "feat",
text: "Extraction de la navbar dans un composant AppNavbar dédié",
},
{
type: "feat",
text: "Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables",
},
{
type: "perf",
text: "Optimisations API : helper extractCollection partagé, invalidation de cache ciblée",
},
{
type: "chore",
text: "Migration des composables JavaScript vers TypeScript strict",
},
{
type: "chore",
text: "Activation de règles ESLint strictes et suppression de 19 console.log de débogage",
},
],
},
{
version: "v1.2.0",
date: "2026-01-21",
changes: [
{
type: "feat",
text: "Système de suivi d'historique (audit) avec enregistrement automatique des modifications sur toutes les entités",
},
{
type: "feat",
text: "Interface dédiée à l'historique sur les fiches produits, pièces et composants",
},
{
type: "feat",
text: "Modale d'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d'édition",
},
{
type: "feat",
text: "Possibilité d'ajouter des champs personnalisés en mode restreint sur les catégories",
},
],
},
{
version: "v1.1.1",
date: "2026-01-14",
changes: [
{
type: "feat",
text: "Compression automatique des fichiers PDF à l'upload via qpdf, réduisant l'espace de stockage",
},
{
type: "chore",
text: "Ajout de qpdf dans l'image Docker pour le support de la compression PDF",
},
],
},
{
version: "v1.1.0",
date: "2026-01-07",
changes: [
{
type: "fix",
text: "Recherche insensible à la casse sur l'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)",
},
{
type: "chore",
text: "Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement",
},
{
type: "chore",
text: "Mise à jour des fixtures avec les données courantes de la base",
},
],
},
{
version: "v1.0.0",
date: "2025-12-15",
changes: [
{
type: "feat",
text: "Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces",
},
{
type: "feat",
text: "Catalogues composants, pièces et produits avec recherche serveur, tri et pagination",
},
{
type: "feat",
text: "Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner",
},
{
type: "feat",
text: "Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux",
},
{
type: "feat",
text: "Gestion des fournisseurs multiples avec résolution automatique des noms",
},
{
type: "feat",
text: "Exigences produit sur les pièces : support de liaisons multiples",
},
{
type: "feat",
text: "Sélections de composants sur les pièces avec recherche dynamique",
},
{
type: "feat",
text: "Système de sessions utilisateurs avec authentification par cookie",
},
{
type: "feat",
text: "Mémorisation des préférences de tri par catalogue (cookies)",
},
{
type: "feat",
text: "Formatage automatique des contacts et des montants en format français",
},
{
type: "feat",
text: "Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation",
},
{
type: "chore",
text: "Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin",
},
],
},
];
</script> </script>

View File

@@ -4,7 +4,7 @@
<h1 class="text-3xl font-semibold text-base-content"> <h1 class="text-3xl font-semibold text-base-content">
Commentaires Commentaires
</h1> </h1>
<p class="text-sm text-gray-500"> <p class="text-sm text-base-content/50">
Liste de tous les commentaires et tickets ouverts sur les fiches. Liste de tous les commentaires et tickets ouverts sur les fiches.
</p> </p>
</header> </header>
@@ -73,7 +73,10 @@
</template> </template>
<template #cell-content="{ row }"> <template #cell-content="{ row }">
<span class="line-clamp-2 text-sm">{{ row.content }}</span> <div class="tooltip tooltip-top max-w-xs" :data-tip="row.content">
<span class="line-clamp-2 text-sm text-left">{{ row.content }}</span>
</div>
<CommentDocumentList :documents="getDocuments(row)" />
</template> </template>
<template #cell-entityType="{ row }"> <template #cell-entityType="{ row }">
@@ -132,7 +135,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, type Ref } from 'vue' import { ref, computed, onMounted, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import { useComments, type Comment } from '~/composables/useComments' import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
import CommentDocumentList from '~/components/CommentDocumentList.vue'
import { usePermissions } from '~/composables/usePermissions' import { usePermissions } from '~/composables/usePermissions'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import IconLucideCheck from '~icons/lucide/check' import IconLucideCheck from '~icons/lucide/check'
@@ -148,6 +152,9 @@ const comments = ref<Comment[]>([])
const total = ref(0) const total = ref(0)
const loadingList = ref(true) const loadingList = ref(true)
const getDocuments = (comment: Comment): CommentDocument[] =>
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
const table = useDataTable( const table = useDataTable(
{ fetchData: loadComments }, { fetchData: loadComments },
{ {
@@ -159,6 +166,7 @@ const table = useDataTable(
status: { default: 'open' }, status: { default: 'open' },
entityType: { default: '' }, entityType: { default: '' },
}, },
columnFilterKeys: ['entity'],
}, },
) )
@@ -236,9 +244,9 @@ const handleResolve = async (commentId: string) => {
const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = { const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = {
machine: (id: string) => `/machine/${id}`, machine: (id: string) => `/machine/${id}`,
piece: (id: string) => `/pieces/${id}/edit`, piece: (id: string) => `/piece/${id}`,
composant: (id: string) => `/component/${id}/edit`, composant: (id: string) => `/component/${id}`,
product: (id: string) => `/product/${id}/edit`, product: (id: string) => `/product/${id}`,
piece_category: (id: string) => `/piece-category/${id}/edit`, piece_category: (id: string) => `/piece-category/${id}/edit`,
component_category: (id: string) => `/component-category/${id}/edit`, component_category: (id: string) => `/component-category/${id}/edit`,
product_category: (id: string) => `/product-category/${id}/edit`, product_category: (id: string) => `/product-category/${id}/edit`,

View File

@@ -1,9 +1,9 @@
<template> <template>
<main class="container mx-auto px-6 py-10 space-y-8"> <main class="container mx-auto px-6 py-10 space-y-8">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <header class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div> <div>
<h1 class="text-3xl font-semibold text-base-content">Catalogue des composants</h1> <h1 class="text-3xl font-bold text-base-content tracking-tight">Catalogue des composants</h1>
<p class="text-sm text-gray-500"> <p class="text-sm text-base-content/50 mt-1">
Consultez et gérez tous les composants existants. Consultez et gérez tous les composants existants.
</p> </p>
</div> </div>
@@ -11,17 +11,17 @@
<NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md"> <NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un composant Ajouter un composant
</NuxtLink> </NuxtLink>
<NuxtLink to="/component-category" class="btn btn-outline btn-sm md:btn-md"> <NuxtLink to="/component-category" class="btn btn-ghost btn-sm md:btn-md">
Gérer les catégories Gérer les catégories
</NuxtLink> </NuxtLink>
</div> </div>
</header> </header>
<section class="card border border-base-200 bg-base-100 shadow-sm"> <section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<header class="flex flex-col gap-2"> <header class="flex flex-col gap-1">
<h2 class="text-xl font-semibold text-base-content">Composants créés</h2> <h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
<p class="text-sm text-base-content/70"> <p class="text-sm text-base-content/50">
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie. Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
</p> </p>
</header> </header>
@@ -95,22 +95,30 @@
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-2"> <div class="flex items-center justify-end gap-2">
<NuxtLink
:to="`/component/${row.component.id}/edit`"
class="btn btn-ghost btn-xs"
>
Modifier
</NuxtLink>
<button <button
v-if="canEdit" v-if="canEdit"
type="button" type="button"
class="btn btn-error btn-xs" class="btn btn-ghost btn-xs"
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingComposants" :disabled="loadingComposants"
@click="handleDeleteComponent(row.component)" @click="handleDeleteComponent(row.component)"
> >
Supprimer Supprimer
</button> </button>
<NuxtLink
:to="`/component/${row.component.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div> </div>
</template> </template>
</DataTable> </DataTable>
@@ -124,20 +132,19 @@ import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import { useComposants } from '~/composables/useComposants' import { useComposants } from '~/composables/useComposants'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const { showError } = useToast() const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes() const { componentTypes, loadComponentTypes } = useComponentTypes()
const loadingComposants = computed(() => loadingComposantsRef.value)
const table = useDataTable( const table = useDataTable(
{ fetchData: fetchComposants }, { fetchData: fetchComposants },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true }, { defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
) )
const columns = [ const columns = [
@@ -180,63 +187,24 @@ async function fetchComposants() {
}) })
} }
const resolvePrimaryDocument = (component: Record<string, any>) => {
const documents = Array.isArray(component?.documents) ? component.documents : []
if (!documents.length) return null
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
if (pdf) return pdf
const image = withPath.find((doc: any) => isImageDocument(doc))
if (image) return image
return withPath[0] ?? normalized[0] ?? null
}
const resolvePreviewAlt = (component: Record<string, any>) => {
const parts = [component?.name, component?.reference].filter(Boolean)
return parts.length ? `Aperçu du document de ${parts.join(' ')}` : 'Aperçu du document'
}
const resolveComponentType = (component: Record<string, any>) => { const resolveComponentType = (component: Record<string, any>) => {
if (component?.typeComposant?.name) return component.typeComposant.name if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel if (component?.typeComposantLabel) return component.typeComposantLabel
return '—' return '—'
} }
const resolveDeleteGuard = (component: Record<string, any>) => { const { confirm } = useConfirm()
const blockingReasons: string[] = []
const machineLinks = Array.isArray(component?.machineLinks) ? component.machineLinks.length : component?.machineLinksCount ?? 0
const documents = Array.isArray(component?.documents) ? component.documents.length : component?.documentsCount ?? 0
const customFields = Array.isArray(component?.customFieldValues) ? component.customFieldValues.length : component?.customFieldValuesCount ?? 0
if (machineLinks > 0) blockingReasons.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
if (documents > 0) blockingReasons.push(`${documents} document${documents > 1 ? 's' : ''}`)
return { blockingReasons, hasCustomFields: customFields > 0 }
}
const handleDeleteComponent = async (component: Record<string, any>) => { const handleDeleteComponent = async (component: Record<string, any>) => {
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
if (blockingReasons.length) {
showError(`Impossible de supprimer ce composant car il possède encore: ${blockingReasons.join(', ')}. Supprimez ou détachez ces éléments avant de réessayer.`)
return
}
const componentName = component?.name || 'ce composant' const componentName = component?.name || 'ce composant'
const confirmLines = [`Voulez-vous vraiment supprimer ${componentName} ?`] const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
if (hasCustomFields) { const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
confirmLines.push('Les valeurs de champs personnalisés associées seront également supprimées.')
}
const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) return if (!confirmed) return
await deleteComposant(component.id) await deleteComposant(component.id)
fetchComposants() fetchComposants()
} }
const formatDate = (dateStr: string) => { const formatDate = formatFrenchDate
if (!dateStr) return '—'
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' }).format(date)
}
onMounted(async () => { onMounted(async () => {
await Promise.all([fetchComposants(), loadComponentTypes()]) await Promise.all([fetchComposants(), loadComponentTypes()])

View File

@@ -27,10 +27,6 @@
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:readonly="!canEdit" :readonly="!canEdit"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -45,6 +41,14 @@
show-resolved show-resolved
/> />
</div> </div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main> </main>
</template> </template>
@@ -52,9 +56,8 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports' import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue' import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes' import { getModelType, updateModelType, syncPreview, syncExecute, type ModelTypePayload, type SyncPreviewResult } from '~/services/modelTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory' import type { ComponentModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useComponentTypes } from '~/composables/useComponentTypes' import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
@@ -67,23 +70,10 @@ const { loadComponentTypes } = useComponentTypes()
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null) const initialData = ref<Partial<ModelTypePayload> | null>(null)
const showSyncModal = ref(false)
const { const syncLoading = ref(false)
isRestrictedMode, const syncPreviewData = ref<SyncPreviewResult | null>(null)
isSubmitBlocked, const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/composants',
filterKey: 'typeComposant',
labels: {
singular: 'composant',
plural: 'composants',
verifying: 'Vérification des composants liés en cours…',
},
})
const title = computed(() => const title = computed(() =>
initialData.value?.name initialData.value?.name
@@ -124,9 +114,10 @@ const loadCategory = async () => {
category: response.category, category: response.category,
notes: response.notes ?? response.description ?? '', notes: response.notes ?? response.description ?? '',
structure: (response.structure as ComponentModelStructure | null) ?? undefined, structure: (response.structure as ComponentModelStructure | null) ?? undefined,
referenceFormula: response.referenceFormula ?? null,
requiredFieldsForReference: response.requiredFieldsForReference ?? null,
} }
await loadLinkedCount(id)
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
await navigateBackToList() await navigateBackToList()
@@ -141,9 +132,6 @@ const handleCancel = () => {
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return if (!canEdit.value) return
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id) const id = String(route.params.id)
saving.value = true saving.value = true
try { try {
@@ -151,10 +139,28 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
...payload, ...payload,
description: payload?.notes ?? null, description: payload?.notes ?? null,
} }
await updateModelType(id, enrichedPayload)
await loadComponentTypes({ force: true }) // Get sync preview BEFORE saving
showSuccess('Catégorie de composant mise à jour avec succès.') const preview = await syncPreview(id, enrichedPayload.structure || {})
await navigateBackToList()
const hasImpact = preview && (
Object.values(preview.additions || {}).some(v => v > 0)
|| Object.values(preview.deletions || {}).some(v => v > 0)
|| Object.values(preview.modifications || {}).some(v => v > 0)
)
if (hasImpact) {
// Show modal for confirmation
pendingPayload.value = enrichedPayload
syncPreviewData.value = preview
showSyncModal.value = true
} else {
// No impact — save directly + sync
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
}
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
} finally { } finally {
@@ -162,6 +168,38 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
} }
} }
const handleSyncConfirm = async () => {
if (!pendingPayload.value) return
const id = String(route.params.id)
syncLoading.value = true
try {
const hasDeletions = syncPreviewData.value && Object.values(syncPreviewData.value.deletions || {}).some(v => v > 0)
const hasModifications = syncPreviewData.value && Object.values(syncPreviewData.value.modifications || {}).some(v => v > 0)
await updateModelType(id, pendingPayload.value)
await syncExecute(id, {
confirmDeletions: !!hasDeletions,
confirmTypeChanges: !!hasModifications,
})
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))
} finally {
syncLoading.value = false
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
}
const handleSyncCancel = () => {
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
onMounted(() => { onMounted(() => {
loadCategory() loadCategory()
}) })

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
</button> </button>
</div> </div>
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<DataTable <DataTable
:columns="columns" :columns="columns"
@@ -49,11 +49,11 @@
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)"> <button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
{{ canEdit ? 'Modifier' : 'Consulter' }} {{ canEdit ? 'Modifier' : 'Consulter' }}
</button> </button>
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(row)"> <button v-if="canEdit" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
Supprimer Supprimer
</button> </button>
</div> </div>
@@ -100,6 +100,7 @@ import { useConstructeurs } from '~/composables/useConstructeurs'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedValue } from '~/composables/usePersistedValue' import { usePersistedValue } from '~/composables/usePersistedValue'
import { formatPhone } from '~/utils/formatters/phone' import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
@@ -153,16 +154,7 @@ const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value) await searchConstructeurs(searchTerm.value)
}, 300) }, 300)
const formatDate = (dateStr) => { const formatDate = formatFrenchDate
if (!dateStr) return '—'
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',
}).format(date)
}
const formatPhoneDisplay = (value) => { const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value) const formatted = formatPhone(value)

View File

@@ -3,15 +3,22 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="documentRows" :documents="documents"
@close="closePreview" @close="closePreview"
/> />
<section class="card bg-base-100 shadow-lg"> <DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6"> <div class="card-body space-y-6">
<DataTable <DataTable
:columns="columns" :columns="columns"
:rows="documentRows" :rows="documents"
:loading="loading" :loading="loading"
:sort="table.sort.value" :sort="table.sort.value"
:pagination="paginationState" :pagination="paginationState"
@@ -55,6 +62,26 @@
<option value="product">Produits</option> <option value="product">Produits</option>
</select> </select>
</div> </div>
<div class="flex items-center gap-2">
<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>
</div>
</template> </template>
<template #cell-name="{ row }"> <template #cell-name="{ row }">
@@ -68,7 +95,7 @@
</span> </span>
<div> <div>
<div class="font-semibold">{{ row.name }}</div> <div class="font-semibold">{{ row.name }}</div>
<div class="text-xs text-gray-500">{{ row.filename }}</div> <div class="text-xs text-base-content/50">{{ row.filename }}</div>
</div> </div>
</div> </div>
</template> </template>
@@ -77,6 +104,10 @@
{{ row.mimeType || 'Inconnu' }} {{ row.mimeType || 'Inconnu' }}
</template> </template>
<template #cell-type="{ row }">
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(row.type || 'documentation') }}</span>
</template>
<template #cell-size="{ row }"> <template #cell-size="{ row }">
{{ formatSize(row.size) }} {{ formatSize(row.size) }}
</template> </template>
@@ -88,7 +119,7 @@
<span v-else-if="row.composant">Composant &middot; {{ row.composant.name }}</span> <span v-else-if="row.composant">Composant &middot; {{ row.composant.name }}</span>
<span v-else-if="row.piece">Pi&egrave;ce &middot; {{ row.piece.name }}</span> <span v-else-if="row.piece">Pi&egrave;ce &middot; {{ row.piece.name }}</span>
<span v-else-if="row.product">Produit &middot; {{ row.product.name }}</span> <span v-else-if="row.product">Produit &middot; {{ row.product.name }}</span>
<span v-else class="text-gray-400">Non d&eacute;fini</span> <span v-else class="text-base-content/30">Non d&eacute;fini</span>
</div> </div>
</template> </template>
@@ -98,6 +129,14 @@
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button
v-if="canEdit"
class="btn btn-ghost btn-xs"
type="button"
@click="openEditModal(row)"
>
Modifier
</button>
<button <button
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
type="button" type="button"
@@ -123,12 +162,15 @@ import { computed, onMounted, ref, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import { usePermissions } from '~/composables/usePermissions'
import { getFileIcon } from '~/utils/fileIcons' import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
import { formatFrenchDate } from '~/utils/date' import { formatFrenchDate } from '~/utils/date'
import { DOCUMENT_TYPES, getDocumentTypeLabel } from '~/shared/documentTypes'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
const { documents, total, loading, loadDocuments } = useDocuments() const { documents, total, loading, loadDocuments, updateDocument } = useDocuments()
const { canEdit } = usePermissions()
const table = useDataTable( const table = useDataTable(
{ fetchData: fetchDocuments }, { fetchData: fetchDocuments },
@@ -139,22 +181,26 @@ const table = useDataTable(
persistToUrl: true, persistToUrl: true,
extraParams: { extraParams: {
filter: { default: 'all' }, filter: { default: 'all' },
typeFilter: { default: 'all' },
}, },
}, },
) )
const attachmentFilter = table.filters.filter as Ref<string> const attachmentFilter = table.filters.filter as Ref<string>
const typeFilter = table.filters.typeFilter as Ref<string>
const previewDocument = ref<any>(null) const previewDocument = ref<any>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const editingDocument = ref<any>(null)
const editModalVisible = ref(false)
const documentRows = computed(() => documents.value)
const documentsOnPage = computed(() => documents.value.length) const documentsOnPage = computed(() => documents.value.length)
const paginationState = table.pagination(total, documentsOnPage) const paginationState = table.pagination(total, documentsOnPage)
const columns = [ const columns = [
{ key: 'name', label: 'Nom', sortable: true, sortKey: 'name' }, { key: 'name', label: 'Nom', sortable: true, sortKey: 'name' },
{ key: 'mimeType', label: 'Type' }, { key: 'mimeType', label: 'Type MIME' },
{ key: 'type', label: 'Type' },
{ key: 'size', label: 'Taille', sortable: true, sortKey: 'size' }, { key: 'size', label: 'Taille', sortable: true, sortKey: 'size' },
{ key: 'attachment', label: 'Rattaché à' }, { key: 'attachment', label: 'Rattaché à' },
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' }, { key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
@@ -169,6 +215,7 @@ async function fetchDocuments() {
orderBy: table.sortField.value, orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc', orderDir: table.sortDirection.value as 'asc' | 'desc',
attachmentFilter: attachmentFilter.value, attachmentFilter: attachmentFilter.value,
type: typeFilter.value,
force: true, force: true,
}) })
} }
@@ -199,6 +246,25 @@ const closePreview = () => {
previewDocument.value = null previewDocument.value = null
} }
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name: string; type: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const doc = documents.value.find((d) => d.id === editingDocument.value.id)
if (doc) {
doc.name = data.name
doc.type = data.type
}
}
editModalVisible.value = false
editingDocument.value = null
}
onMounted(() => { onMounted(() => {
fetchDocuments() fetchDocuments()
}) })

View File

@@ -1,57 +1,51 @@
<template> <template>
<main class="container mx-auto px-6 py-8"> <main class="container mx-auto px-6 py-8">
<!-- Hierarchical View -->
<div class="my-8"> <div class="my-8">
<!-- Header with Stats --> <!-- Header with Stats -->
<div class="flex justify-between items-center mb-6"> <div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between mb-8">
<div> <div>
<h2 class="text-2xl font-bold text-gray-800"> <h2 class="text-3xl font-bold text-base-content tracking-tight">
Vue d'ensemble Vue d'ensemble
</h2> </h2>
<p class="text-gray-600"> <p class="text-base-content/50 mt-1">
Machines organisées par site Machines organisées par site
</p> </p>
</div> </div>
<div class="stats shadow"> <div class="flex gap-3">
<div class="stat"> <div class="bg-base-100 rounded-xl border border-base-300/50 px-5 py-3 shadow-sm">
<div class="stat-title"> <p class="text-[0.65rem] font-semibold uppercase tracking-widest text-base-content/40 mb-0.5">Sites</p>
Sites <p class="text-2xl font-bold text-primary tracking-tight">{{ sites.length }}</p>
</div>
<div class="stat-value text-primary">
{{ sites.length }}
</div>
</div> </div>
<div class="stat"> <div class="bg-base-100 rounded-xl border border-base-300/50 px-5 py-3 shadow-sm">
<div class="stat-title"> <p class="text-[0.65rem] font-semibold uppercase tracking-widest text-base-content/40 mb-0.5">Machines</p>
Machines <p class="text-2xl font-bold text-secondary tracking-tight">{{ totalMachines }}</p>
</div>
<div class="stat-value text-secondary">
{{ totalMachines }}
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="card bg-base-100 shadow-lg mb-6"> <div class="card bg-base-100 shadow-sm mb-8">
<div class="card-body"> <div class="card-body py-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="flex flex-col md:flex-row md:items-end gap-4">
<div class="form-control"> <div class="form-control flex-1">
<label class="label"> <label class="label">
<span class="label-text">Rechercher</span> <span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Rechercher</span>
</label> </label>
<input <div class="relative">
v-model="searchTerm" <IconLucideSearch class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/30" aria-hidden="true" />
type="text" <input
placeholder="Nom de machine ou site..." v-model="searchTerm"
class="input input-bordered" type="text"
> placeholder="Nom de machine ou site..."
class="input input-bordered pl-10 w-full"
>
</div>
</div> </div>
<div class="form-control"> <div class="form-control md:w-64">
<label class="label"> <label class="label">
<span class="label-text">Site</span> <span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
</label> </label>
<select v-model="selectedSiteFilter" class="select select-bordered"> <select v-model="selectedSiteFilter" class="select select-bordered w-full">
<option value=""> <option value="">
Tous les sites Tous les sites
</option> </option>
@@ -69,30 +63,32 @@
</div> </div>
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-12"> <div v-if="loading" class="flex justify-center items-center py-16">
<span class="loading loading-spinner loading-lg" /> <span class="loading loading-spinner loading-lg text-primary" />
</div> </div>
<!-- Hierarchical Machines View --> <!-- Empty State -->
<div v-else-if="filteredSites.length === 0" class="text-center py-12"> <div v-else-if="filteredSites.length === 0" class="text-center py-16">
<div class="max-w-md mx-auto"> <div class="max-w-sm mx-auto">
<IconLucideFactory <div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
class="w-16 h-16 mx-auto text-gray-400 mb-4" <IconLucideFactory
aria-hidden="true" class="w-8 h-8 text-base-content/30"
/> aria-hidden="true"
<h3 class="text-lg font-medium text-gray-900 mb-2"> />
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">
Aucune machine trouvée Aucune machine trouvée
</h3> </h3>
<p class="text-gray-500 mb-4"> <p class="text-sm text-base-content/50 mb-6">
Commencez par ajouter des sites et des machines. Commencez par ajouter des sites et des machines.
</p> </p>
<div class="flex gap-2 justify-center"> <div class="flex gap-2 justify-center">
<button v-if="canEdit" class="btn btn-primary" @click="showAddSiteModal = true"> <button v-if="canEdit" class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site Ajouter un site
</button> </button>
<button <button
v-if="canEdit" v-if="canEdit"
class="btn btn-secondary" class="btn btn-ghost btn-sm"
@click="showAddMachineModal = true" @click="showAddMachineModal = true"
> >
Ajouter une machine Ajouter une machine
@@ -101,130 +97,119 @@
</div> </div>
</div> </div>
<div v-else class="space-y-6"> <!-- Sites List -->
<div v-else class="space-y-5">
<div <div
v-for="site in filteredSites" v-for="site in filteredSites"
:key="site.id" :key="site.id"
class="card bg-base-100 shadow-lg" class="card site-card shadow-md overflow-hidden"
:style="{
borderTop: site.color ? `4px solid ${site.color}` : '4px solid transparent',
background: site.color ? `linear-gradient(160deg, ${site.color}30 0%, ${site.color}08 40%, var(--color-base-100) 100%)` : undefined,
}"
> >
<!-- Site Header -->
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between mb-4"> <!-- Site Header -->
<div class="flex items-center gap-3"> <div class="flex items-start justify-between gap-4">
<div class="avatar placeholder"> <div class="flex items-start gap-4">
<div <div
class="bg-primary text-primary-content rounded-lg w-12 grid place-items-center" class="w-11 h-11 rounded-xl grid place-items-center shrink-0"
> :style="{ backgroundColor: site.color ? site.color + '40' : 'oklch(var(--p) / 0.1)', border: site.color ? `2px solid ${site.color}60` : 'none' }"
<IconLucideMapPin class="w-6 h-6" aria-hidden="true" /> >
</div> <IconLucideMapPin class="w-5 h-5" :style="{ color: site.color || 'oklch(var(--p))' }" aria-hidden="true" />
</div> </div>
<div> <div class="min-w-0">
<h3 class="text-xl font-bold"> <h3
class="text-lg font-bold tracking-tight text-base-content"
>
{{ site.name }} {{ site.name }}
</h3> </h3>
<div class="text-sm text-gray-600 space-y-1"> <div class="flex flex-wrap gap-x-4 gap-y-1 mt-1.5 text-sm text-base-content/50">
<div class="flex items-center gap-2"> <span v-if="site.contactName" class="flex items-center gap-1.5">
<IconLucideUser <IconLucideUser class="w-3.5 h-3.5" aria-hidden="true" />
class="w-4 h-4 text-primary" {{ site.contactName }}
aria-hidden="true" </span>
/> <span v-if="site.contactPhone" class="flex items-center gap-1.5">
<span class="font-medium">{{ site.contactName }}</span> <IconLucidePhone class="w-3.5 h-3.5" aria-hidden="true" />
</div> {{ formatPhoneDisplay(site.contactPhone) }}
<div class="flex items-center gap-2"> </span>
<IconLucidePhone <span v-if="site.contactCity" class="flex items-center gap-1.5">
class="w-4 h-4 text-secondary" <IconLucideMapPinned class="w-3.5 h-3.5" aria-hidden="true" />
aria-hidden="true" {{ site.contactPostalCode }} {{ site.contactCity }}
/> </span>
<span>{{ formatPhoneDisplay(site.contactPhone) }}</span>
</div>
<div class="flex items-start gap-2">
<IconLucideMapPinned
class="w-4 h-4 text-accent mt-1"
aria-hidden="true"
/>
<span>
{{ site.contactAddress }}<br>
{{ site.contactPostalCode }} {{ site.contactCity }}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 shrink-0">
<div class="badge badge-primary badge-lg"> <span
{{ site.machines?.length || 0 }} machines class="badge font-bold"
</div> :style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
:class="!site.color ? 'badge-primary' : ''"
>
{{ site.machines?.length || 0 }}
</span>
<button <button
class="btn btn-ghost btn-sm" class="btn btn-ghost btn-xs btn-circle"
@click="toggleSiteCollapse(site.id)" @click="toggleSiteCollapse(site.id)"
> >
<IconLucideChevronDown <IconLucideChevronDown
class="w-5 h-5 transition-transform" class="w-4 h-4 transition-transform duration-200"
:class=" :class="collapsedSites.includes(site.id) ? 'rotate-180' : ''"
collapsedSites.includes(site.id) ? 'rotate-180' : ''
"
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
</div> </div>
</div> </div>
<!-- Machines List --> <!-- Machines Grid -->
<div <div
v-if=" v-if="
!collapsedSites.includes(site.id) && !collapsedSites.includes(site.id) &&
site.machines && site.machines &&
site.machines.length > 0 site.machines.length > 0
" "
class="space-y-3" class="mt-4 pt-4 border-t border-base-200/80"
> >
<div class="divider" /> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div <div
v-for="machine in site.machines" v-for="machine in site.machines"
:key="machine.id" :key="machine.id"
class="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer" class="group flex flex-col rounded-xl border border-base-300/40 bg-base-100 shadow-sm hover:shadow-md hover:border-primary/20 transition-all cursor-pointer p-4"
@click="viewMachineDetails(machine)" @click="viewMachineDetails(machine)"
> >
<div class="card-body p-4"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center justify-between mb-2"> <h4 class="font-semibold text-sm text-base-content group-hover:text-primary transition-colors truncate">
<h4 class="font-semibold text-sm"> {{ machine.name }}
{{ machine.name }} </h4>
</h4> </div>
</div>
<div class="space-y-1 text-xs text-gray-600"> <div v-if="machine.reference" class="flex items-center gap-1.5 text-xs text-base-content/40">
<div <IconLucideTag class="w-3 h-3" aria-hidden="true" />
v-if="machine.reference" <span>{{ machine.reference }}</span>
class="flex items-center gap-1" </div>
>
<IconLucideTag class="w-3 h-3" aria-hidden="true" />
<span>{{ machine.reference }}</span>
</div>
</div>
<div class="card-actions justify-end mt-3"> <div class="mt-auto pt-3 flex items-center justify-end gap-2">
<button <button
v-if="canEdit" v-if="canEdit"
class="btn btn-xs btn-outline" class="btn btn-ghost btn-sm"
@click.stop="editMachine(machine)" @click.stop="editMachine(machine)"
> >
Modifier Modifier
</button> </button>
<button <button
v-if="canEdit" v-if="canEdit"
class="btn btn-xs btn-error" class="btn btn-ghost btn-sm text-error"
@click.stop="confirmDeleteMachine(machine)" @click.stop="confirmDeleteMachine(machine)"
> >
Supprimer Supprimer
</button> </button>
<NuxtLink <NuxtLink
:to="`/machine/${machine.id}`" :to="`/machine/${machine.id}`"
class="btn btn-xs btn-primary" class="btn btn-primary btn-sm"
> @click.stop
Détails >
</NuxtLink> Détails
</div> </NuxtLink>
</div> </div>
</div> </div>
</div> </div>
@@ -236,17 +221,17 @@
!collapsedSites.includes(site.id) && !collapsedSites.includes(site.id) &&
(!site.machines || site.machines.length === 0) (!site.machines || site.machines.length === 0)
" "
class="text-center py-6" class="text-center py-8 mt-4 border-t border-base-200/80"
> >
<div class="text-gray-400 mb-2"> <div class="w-10 h-10 rounded-xl bg-base-200 grid place-items-center mx-auto mb-3">
<IconLucideFactory class="w-8 h-8 mx-auto" aria-hidden="true" /> <IconLucideFactory class="w-5 h-5 text-base-content/25" aria-hidden="true" />
</div> </div>
<p class="text-sm text-gray-500 mb-3"> <p class="text-sm text-base-content/40 mb-3">
Aucune machine dans ce site Aucune machine dans ce site
</p> </p>
<button <button
v-if="canEdit" v-if="canEdit"
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary btn-outline"
@click="addMachineToSite(site)" @click="addMachineToSite(site)"
> >
Ajouter une machine Ajouter une machine
@@ -258,120 +243,27 @@
</div> </div>
<!-- Add Site Modal --> <!-- Add Site Modal -->
<div v-if="showAddSiteModal" class="modal modal-open"> <AddSiteModal
<div class="modal-box"> :open="showAddSiteModal"
<h3 class="font-bold text-lg mb-4"> :disabled="!canEdit"
Ajouter un nouveau site @close="showAddSiteModal = false"
</h3> @create="handleCreateSite"
<form class="space-y-4" @submit.prevent="handleCreateSite"> />
<div class="form-control">
<label class="label">
<span class="label-text">Nom du site</span>
</label>
<input
v-model="newSite.name"
type="text"
placeholder="Ex: Usine de production"
class="input input-bordered"
:disabled="!canEdit"
required
>
</div>
<SiteContactFormFields :form="newSite" :disabled="!canEdit" />
<div class="modal-action">
<button
type="button"
class="btn btn-outline"
@click="showAddSiteModal = false"
>
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
Créer le site
</button>
</div>
</form>
</div>
</div>
<!-- Add Machine Modal --> <!-- Add Machine Modal -->
<div v-if="showAddMachineModal" class="modal modal-open"> <AddMachineModal
<div class="modal-box max-w-2xl"> :open="showAddMachineModal"
<h3 class="font-bold text-lg mb-4"> :sites="sites"
Ajouter une nouvelle machine :disabled="!canEdit"
</h3> :preselected-site-id="preselectedSiteId"
<form @submit.prevent="handleCreateMachine"> @close="showAddMachineModal = false"
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> @create="handleCreateMachine"
<div class="form-control"> />
<label class="label">
<span class="label-text">Nom de la machine</span>
</label>
<input
v-model="newMachine.name"
type="text"
placeholder="Ex: Presse hydraulique #1"
class="input input-bordered"
:disabled="!canEdit"
required
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Site</span>
</label>
<select
v-model="newMachine.siteId"
class="select select-bordered"
:disabled="!canEdit"
required
>
<option value="">
Sélectionner un site
</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered"
:disabled="!canEdit"
>
</div>
<div class="modal-action">
<button
type="button"
class="btn btn-outline"
@click="showAddMachineModal = false"
>
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
Créer la machine
</button>
</div>
</form>
</div>
</div>
</main> </main>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useMachines } from '~/composables/useMachines' import { useMachines } from '~/composables/useMachines'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
@@ -383,6 +275,7 @@ import IconLucidePhone from '~icons/lucide/phone'
import IconLucideMapPinned from '~icons/lucide/map-pinned' import IconLucideMapPinned from '~icons/lucide/map-pinned'
import IconLucideChevronDown from '~icons/lucide/chevron-down' import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideTag from '~icons/lucide/tag' import IconLucideTag from '~icons/lucide/tag'
import IconLucideSearch from '~icons/lucide/search'
import { formatPhone } from '~/utils/formatters/phone' import { formatPhone } from '~/utils/formatters/phone'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
@@ -396,21 +289,7 @@ const showAddMachineModal = ref(false)
const searchTerm = ref('') const searchTerm = ref('')
const selectedSiteFilter = ref('') const selectedSiteFilter = ref('')
const collapsedSites = ref([]) const collapsedSites = ref([])
const preselectedSiteId = ref('')
const newSite = reactive({
name: '',
contactName: '',
contactPhone: '',
contactAddress: '',
contactPostalCode: '',
contactCity: ''
})
const newMachine = reactive({
name: '',
siteId: '',
reference: ''
})
// Computed // Computed
const machinesBySiteId = computed(() => { const machinesBySiteId = computed(() => {
@@ -491,39 +370,17 @@ const filteredSites = computed(() => {
}) })
// Methods // Methods
const handleCreateSite = async () => { const handleCreateSite = async (data) => {
const result = await createSite({ const result = await createSite(data)
name: newSite.name,
contactName: newSite.contactName,
contactPhone: newSite.contactPhone,
contactAddress: newSite.contactAddress,
contactPostalCode: newSite.contactPostalCode,
contactCity: newSite.contactCity
})
if (result.success) { if (result.success) {
showAddSiteModal.value = false showAddSiteModal.value = false
// Reset form
newSite.name = ''
newSite.contactName = ''
newSite.contactPhone = ''
newSite.contactAddress = ''
newSite.contactPostalCode = ''
newSite.contactCity = ''
} }
} }
const handleCreateMachine = async () => { const handleCreateMachine = async (data) => {
const result = await createMachine({ const result = await createMachine(data)
name: newMachine.name,
siteId: newMachine.siteId,
reference: newMachine.reference
})
if (result.success) { if (result.success) {
newMachine.name = ''
newMachine.siteId = ''
newMachine.reference = ''
showAddMachineModal.value = false showAddMachineModal.value = false
await loadMachines() await loadMachines()
} }
@@ -539,12 +396,10 @@ const toggleSiteCollapse = (siteId) => {
} }
const viewMachineDetails = (machine) => { const viewMachineDetails = (machine) => {
// Navigation vers la page de détails de la machine
navigateTo(`/machine/${machine.id}`) navigateTo(`/machine/${machine.id}`)
} }
const editMachine = (machine) => { const editMachine = (machine) => {
// Rediriger vers la page d'édition de la machine
navigateTo(`/machine/${machine.id}?edit=true`) navigateTo(`/machine/${machine.id}?edit=true`)
} }
@@ -573,7 +428,7 @@ const confirmDeleteMachine = async (machine) => {
} }
const addMachineToSite = (site) => { const addMachineToSite = (site) => {
newMachine.siteId = site.id preselectedSiteId.value = site.id
showAddMachineModal.value = true showAddMachineModal.value = true
} }

View File

@@ -1,8 +1,9 @@
<template> <template>
<main class="container mx-auto px-6 py-8"> <div>
<main class="container mx-auto px-6 py-8">
<!-- Loading State --> <!-- Loading State -->
<div v-if="d.loading.value" class="flex justify-center items-center py-12"> <div v-if="d.loading.value" class="flex justify-center items-center py-16">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg text-primary"></span>
</div> </div>
<!-- Machine Details --> <!-- Machine Details -->
@@ -38,7 +39,11 @@
rounded rounded
> >
<div class="flex justify-center gap-4"> <div class="flex justify-center gap-4">
<div v-if="d.machine.value.site?.name" class="badge badge-outline"> <div
v-if="d.machine.value.site?.name"
class="badge badge-outline font-semibold"
:style="d.machine.value.site?.color ? { borderColor: d.machine.value.site.color + '60', backgroundColor: d.machine.value.site.color + '25', color: d.machine.value.site.color } : {}"
>
{{ d.machine.value.site?.name }} {{ d.machine.value.site?.name }}
</div> </div>
<div v-if="d.machine.value.reference" class="badge badge-outline"> <div v-if="d.machine.value.reference" class="badge badge-outline">
@@ -49,24 +54,34 @@
<!-- Machine Info Card --> <!-- Machine Info Card -->
<MachineInfoCard <MachineInfoCard
ref="machineInfoCardRef"
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value" :machine-name="d.machineName.value"
:machine-reference="d.machineReference.value" :machine-reference="d.machineReference.value"
:machine-site-id="d.machineSiteId.value"
:machine-site-name="d.machine.value?.site?.name ?? ''"
:sites="d.sites.value"
:machine-constructeur-ids="d.machineConstructeurIds.value" :machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value" :machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value" :has-machine-constructeur="d.hasMachineConstructeur.value"
:constructeur-links="d.constructeurLinks.value"
:visible-custom-fields="d.visibleMachineCustomFields.value" :visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId" :get-machine-field-id="d.getMachineFieldId"
:machine-id="machineId"
:machine-custom-field-defs="d.machine.value?.customFields ?? []"
@update:machine-name="d.machineName.value = $event" @update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event" @update:machine-reference="d.machineReference.value = $event"
@update:machine-site-id="d.machineSiteId.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange" @update:constructeur-ids="d.handleMachineConstructeurChange"
@blur-field="d.updateMachineInfo" @update:constructeur-links="d.constructeurLinks.value = $event"
@remove-constructeur-link="handleRemoveConstructeurLink"
@set-custom-field-value="d.setMachineCustomFieldValue" @set-custom-field-value="d.setMachineCustomFieldValue"
@update-custom-field="d.updateMachineCustomField" @custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
/> />
<!-- Documents --> <!-- Documents -->
<MachineDocumentsCard <MachineDocumentsCard
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
:documents="d.machineDocumentsList.value" :documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value" :uploading="d.machineDocumentsUploading.value"
@@ -80,14 +95,16 @@
<!-- Produits associés --> <!-- Produits associés -->
<MachineProductsCard <MachineProductsCard
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
:products="d.machineDirectProducts.value" :products="d.machineDirectProducts.value"
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
@add-product="openAddModal('product')" @add-product="openAddModal('product')"
@remove-product="d.removeProductLink" @remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
/> />
<!-- Components Section --> <!-- Components Section -->
<MachineComponentsCard <MachineComponentsCard
v-if="d.isEditMode.value || d.components.value.length > 0"
:components="d.components.value" :components="d.components.value"
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value" :collapsed="d.componentsCollapsed.value"
@@ -97,11 +114,12 @@
@edit-piece="d.updatePieceFromComponent" @edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.updatePieceCustomField" @custom-field-update="d.updatePieceCustomField"
@add-component="openAddModal('component')" @add-component="openAddModal('component')"
@remove-component="d.removeComponentLink" @remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
/> />
<!-- Machine Pieces Section --> <!-- Machine Pieces Section -->
<MachinePiecesCard <MachinePiecesCard
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
:pieces="d.machinePieces.value" :pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value" :is-edit-mode="d.isEditMode.value"
:collapsed="d.piecesCollapsed.value" :collapsed="d.piecesCollapsed.value"
@@ -110,7 +128,7 @@
@edit-piece="d.editPiece" @edit-piece="d.editPiece"
@custom-field-update="d.updatePieceCustomField" @custom-field-update="d.updatePieceCustomField"
@add-piece="openAddModal('piece')" @add-piece="openAddModal('piece')"
@remove-piece="d.removePieceLink" @remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
@toggle-collapse="d.toggleAllPieces" @toggle-collapse="d.toggleAllPieces"
/> />
@@ -122,6 +140,46 @@
@confirm="handleAddEntity" @confirm="handleAddEntity"
/> />
<!-- Save / Cancel buttons -->
<div v-if="d.isEditMode.value" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button
type="button"
class="btn btn-ghost"
:class="{ 'btn-disabled': d.saving.value }"
@click="d.cancelEdition()"
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!d.canSubmit.value"
@click="submitMachineEdition"
>
<span v-if="d.saving.value" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<!-- Historique -->
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Versions -->
<EntityVersionList
ref="versionListRef"
entity-type="machine"
:entity-id="String(machineId)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="d.loadMachineData()"
/>
<!-- Comments --> <!-- Comments -->
<div class="mt-4"> <div class="mt-4">
<CommentSection <CommentSection
@@ -136,32 +194,36 @@
<!-- Error State --> <!-- Error State -->
<div v-else class="text-center py-12"> <div v-else class="text-center py-12">
<div class="max-w-md mx-auto"> <div class="max-w-md mx-auto">
<IconLucideAlertTriangle class="w-16 h-16 mx-auto text-gray-400 mb-4" aria-hidden="true" /> <div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<h3 class="text-lg font-medium text-gray-900 mb-2">Machine non trouvée</h3> <IconLucideAlertTriangle class="w-8 h-8 text-base-content/30" aria-hidden="true" />
<p class="text-gray-500 mb-4">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p> </div>
<NuxtLink to="/machines" class="btn btn-primary"> <h3 class="text-lg font-semibold text-base-content mb-1">Machine non trouvée</h3>
<p class="text-sm text-base-content/50 mb-6">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p>
<button type="button" class="btn btn-primary" @click="$router.back()">
Retour aux machines Retour aux machines
</NuxtLink> </button>
</div> </div>
</div> </div>
</main> </main>
<MachinePrintSelectionModal <MachinePrintSelectionModal
:open="d.printModalOpen.value" :open="d.printModalOpen.value"
:selection="d.printSelection" :selection="d.printSelection"
:components="d.components.value" :components="d.components.value"
:pieces="d.machinePieces.value" :pieces="d.machinePieces.value"
@close="d.closePrintModal" @close="d.closePrintModal"
@confirm="d.handlePrintConfirm" @confirm="d.handlePrintConfirm"
@select-all="d.setAllPrintSelection(true)" @select-all="d.setAllPrintSelection(true)"
@deselect-all="d.setAllPrintSelection(false)" @deselect-all="d.setAllPrintSelection(false)"
/> />
</div>
</template> </template>
<script setup> <script setup>
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useMachineDetailData } from '~/composables/useMachineDetailData' import { useMachineDetailData } from '~/composables/useMachineDetailData'
import { useEntityHistory } from '~/composables/useEntityHistory'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import PageHero from '~/components/PageHero.vue' import PageHero from '~/components/PageHero.vue'
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue' import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
@@ -172,6 +234,8 @@ import MachineProductsCard from '~/components/machine/MachineProductsCard.vue'
import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vue' import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vue'
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue' import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue' import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue'
import EntityHistorySection from '~/components/common/EntityHistorySection.vue'
import EntityVersionList from '~/components/common/EntityVersionList.vue'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle' import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
const route = useRoute() const route = useRoute()
@@ -183,6 +247,33 @@ if (!machineId) {
} }
const d = useMachineDetailData(machineId) const d = useMachineDetailData(machineId)
const machineInfoCardRef = ref(null)
const versionRefreshKey = ref(0)
const refreshVersions = () => { versionRefreshKey.value++ }
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useEntityHistory('machine')
const historyFieldLabels = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
site: 'Site',
constructeurIds: 'Fournisseurs',
addedComponent: 'Composant ajouté',
removedComponent: 'Composant supprimé',
addedPiece: 'Pièce ajoutée',
removedPiece: 'Pièce supprimée',
addedProduct: 'Produit ajouté',
removedProduct: 'Produit supprimé',
componentLinks: 'Composants liés',
pieceLinks: 'Pièces liées',
productLinks: 'Produits liés',
}
const addModalOpen = ref(false) const addModalOpen = ref(false)
const addModalKind = ref('component') const addModalKind = ref('component')
@@ -192,6 +283,11 @@ const openAddModal = (kind) => {
addModalOpen.value = true addModalOpen.value = true
} }
const handleRemoveConstructeurLink = (constructeurId) => {
const ids = d.machineConstructeurIds.value.filter(id => id !== constructeurId)
d.handleMachineConstructeurChange(ids)
}
const handleAddEntity = async (entityId) => { const handleAddEntity = async (entityId) => {
if (addModalKind.value === 'component') { if (addModalKind.value === 'component') {
await d.addComponentLink(entityId) await d.addComponentLink(entityId)
@@ -200,15 +296,25 @@ const handleAddEntity = async (entityId) => {
} else { } else {
await d.addProductLink(entityId) await d.addProductLink(entityId)
} }
refreshVersions()
} }
const machineViewTitle = computed(() => { const machineViewTitle = computed(() => {
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine' return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
}) })
const submitMachineEdition = async () => {
if (machineInfoCardRef.value?.saveFieldDefinitions) {
await machineInfoCardRef.value.saveFieldDefinitions()
}
await d.submitEdition()
refreshVersions()
}
onMounted(() => { onMounted(() => {
d.loadMachineData() d.loadMachineData()
d.loadInitialData() d.loadInitialData()
loadHistory(String(machineId)).catch(() => {})
if (route.query.edit === 'true' && canEdit.value) { if (route.query.edit === 'true' && canEdit.value) {
d.isEditMode.value = true d.isEditMode.value = true

View File

@@ -11,21 +11,28 @@
</NuxtLink> </NuxtLink>
</div> </div>
<div class="card bg-base-100 shadow-lg mb-6"> <div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body"> <div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Site</span> <span class="label-text">Sites</span>
</label> </label>
<select v-model="selectedSite" class="select select-bordered"> <div class="flex flex-wrap gap-3">
<option value=""> <label
Tous les sites v-for="site in sites"
</option> :key="site.id"
<option v-for="site in sites" :key="site.id" :value="site.id"> class="flex items-center gap-2 cursor-pointer"
{{ site.name }} >
</option> <input
</select> type="checkbox"
class="checkbox checkbox-sm"
:checked="selectedSites.has(site.id)"
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
>
<span class="text-sm">{{ site.name }}</span>
</label>
</div>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -65,10 +72,14 @@
<div <div
v-for="machine in filteredMachines" v-for="machine in filteredMachines"
:key="machine.id" :key="machine.id"
class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow cursor-pointer" class="card site-card shadow-md hover:shadow-xl transition-shadow cursor-pointer overflow-hidden"
:style="{
borderTop: machine.site?.color ? `4px solid ${machine.site.color}` : '4px solid transparent',
background: machine.site?.color ? `linear-gradient(160deg, ${machine.site.color}30 0%, ${machine.site.color}08 40%, var(--color-base-100) 100%)` : undefined,
}"
@click="viewMachineDetails(machine)" @click="viewMachineDetails(machine)"
> >
<div class="card-body"> <div class="card-body flex flex-col">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h3 class="card-title text-lg"> <h3 class="card-title text-lg">
{{ machine.name }} {{ machine.name }}
@@ -77,8 +88,11 @@
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<IconLucideMapPin class="w-4 h-4 text-blue-500" aria-hidden="true" /> <IconLucideMapPin class="w-4 h-4" :style="{ color: machine.site?.color || '#3b82f6' }" aria-hidden="true" />
<span class="text-gray-600">{{ machine.site?.name || 'Site inconnu' }}</span> <span
class="font-bold text-sm px-2.5 py-1 rounded-lg text-base-content"
:style="machine.site?.color ? { backgroundColor: machine.site.color + '30', border: `1px solid ${machine.site.color}40` } : {}"
>{{ machine.site?.name || 'Site inconnu' }}</span>
</div> </div>
<div v-if="machine.reference" class="flex items-center gap-2"> <div v-if="machine.reference" class="flex items-center gap-2">
@@ -87,15 +101,15 @@
</div> </div>
</div> </div>
<div class="card-actions justify-end mt-4"> <div class="mt-auto pt-3 flex items-center justify-end gap-2">
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)"> <button v-if="canEdit" class="btn btn-ghost btn-sm" @click.stop="editMachine(machine)">
Modifier Modifier
</button> </button>
<button v-if="canEdit" class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)"> <button v-if="canEdit" class="btn btn-ghost btn-sm text-error" @click.stop="confirmDeleteMachine(machine)">
Supprimer Supprimer
</button> </button>
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary"> <NuxtLink :to="`/machine/${machine.id}`" class="btn btn-primary btn-sm">
Voir détails Détails
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
@@ -106,11 +120,12 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useMachines } from '~/composables/useMachines' import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { useUrlState } from '~/composables/useUrlState'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideFactory from '~icons/lucide/factory' import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin' import IconLucideMapPin from '~icons/lucide/map-pin'
@@ -121,8 +136,28 @@ const { machines, loading, loadMachines, deleteMachine } = useMachines()
const { sites, loadSites } = useSites() const { sites, loadSites } = useSites()
const toast = useToast() const toast = useToast()
const selectedSite = ref('') const urlState = useUrlState({
const searchQuery = ref('') q: { default: '', debounce: 300 },
sites: { default: '' },
})
const searchQuery = urlState.q
const selectedSites = reactive(new Set())
// Sync URL → selectedSites on load and back/forward
watch(urlState.sites, (val) => {
selectedSites.clear()
if (val) {
for (const id of String(val).split(',')) {
if (id) selectedSites.add(id)
}
}
}, { immediate: true })
// Sync selectedSites → URL
watch(() => [...selectedSites], (ids) => {
urlState.sites.value = ids.join(',')
})
// Enrichir les machines avec les objets site complets // Enrichir les machines avec les objets site complets
const enrichedMachines = computed(() => { const enrichedMachines = computed(() => {
@@ -138,8 +173,8 @@ const enrichedMachines = computed(() => {
const filteredMachines = computed(() => { const filteredMachines = computed(() => {
let filtered = enrichedMachines.value let filtered = enrichedMachines.value
if (selectedSite.value) { if (selectedSites.size > 0) {
filtered = filtered.filter(machine => machine.siteId === selectedSite.value) filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
} }
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
@@ -150,6 +185,10 @@ const filteredMachines = computed(() => {
) )
} }
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
return filtered return filtered
}) })

View File

@@ -20,7 +20,7 @@
</div> </div>
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation"> <form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div class="card bg-base-100 shadow-lg"> <div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6"> <div class="card-body space-y-6">
<!-- Basic fields --> <!-- Basic fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">

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