83 Commits

Author SHA1 Message Date
Matthieu
d4fc0f1fee fix(slots) : check API response before updating local state on slot selection
The save functions (savePieceSlotSelection, saveProductSlotSelection,
saveSubcomponentSlotSelection) were not checking result.success before
updating local state and showing success toast. Since useApi.patch()
never throws, the catch block was dead code and errors were silently
ignored while the UI showed success.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:36 +01:00
185 changed files with 16883 additions and 17021 deletions

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ logs
.env .env
.env.* .env.*
!.env.example !.env.example
# Playwright
e2e/.auth/
playwright-report/
test-results/

178
README.md
View File

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

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,9 +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>@Malio 2025 · v{{ appVersion }}</p> <p class="text-xs text-base-content/40 font-medium tracking-wide">
&copy; Malio {{ new Date().getFullYear() }}
</p>
<NuxtLink
to="/changelog"
class="text-xs text-base-content/40 hover:text-primary transition-colors font-medium"
>
v{{ appVersion }}
</NuxtLink>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -1,50 +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;
--color-base-100: oklch(98% 0.02 240); /* Surfaces — warm gray with a hint of blue */
--color-base-200: oklch(95% 0.03 240); --color-base-100: oklch(98.5% 0.004 260);
--color-base-300: oklch(92% 0.04 240); --color-base-200: oklch(95% 0.008 260);
--color-base-content: oklch(20% 0.05 240); --color-base-300: oklch(91% 0.015 260);
--color-primary: oklch(55% 0.3 240); --color-base-content: oklch(22% 0.025 260);
--color-primary-content: oklch(98% 0.01 240);
--color-secondary: oklch(70% 0.25 200);
--color-secondary-content: oklch(98% 0.01 200);
--color-accent: oklch(65% 0.25 160);
--color-accent-content: oklch(98% 0.01 160);
--color-neutral: oklch(50% 0.05 240);
--color-neutral-content: oklch(98% 0.01 240);
--color-info: oklch(70% 0.2 220);
--color-info-content: oklch(98% 0.01 220);
--color-success: oklch(65% 0.25 140);
--color-success-content: oklch(98% 0.01 140);
--color-warning: oklch(80% 0.25 80);
--color-warning-content: oklch(20% 0.05 80);
--color-error: oklch(65% 0.3 30);
--color-error-content: oklch(98% 0.01 30);
/* border radius */ /* Primary — Malio blue, slightly richer */
--radius-selector: 1rem; --color-primary: oklch(40% 0.16 262);
--radius-field: 0.25rem; --color-primary-content: oklch(98% 0.005 262);
--radius-box: 0.5rem;
/* Secondary — refined lavender */
--color-secondary: oklch(72% 0.06 275);
--color-secondary-content: oklch(22% 0.03 275);
/* Accent — warm amber-orange */
--color-accent: oklch(72% 0.17 55);
--color-accent-content: oklch(20% 0.04 55);
/* Neutral — deep slate */
--color-neutral: oklch(28% 0.04 260);
--color-neutral-content: oklch(95% 0.005 260);
/* Semantic */
--color-info: oklch(58% 0.14 255);
--color-info-content: oklch(98% 0.005 255);
--color-success: oklch(62% 0.19 150);
--color-success-content: oklch(98% 0.005 150);
--color-warning: oklch(78% 0.15 70);
--color-warning-content: oklch(22% 0.05 70);
--color-error: oklch(58% 0.22 25);
--color-error-content: oklch(98% 0.005 25);
/* Geometry */
--radius-selector: 0.75rem;
--radius-field: 0.375rem;
--radius-box: 0.625rem;
/* 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;
@@ -53,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;
@@ -62,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;
@@ -71,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;
@@ -80,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 #3b82f6; 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 {
@@ -332,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,212 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold flex items-center gap-2">
<IconLucideMessageSquare class="w-5 h-5" />
Commentaires
<span v-if="openComments.length" class="badge badge-warning badge-sm">
{{ openComments.length }}
</span>
</h3>
<button
v-if="showResolved && resolvedComments.length"
type="button"
class="btn btn-ghost btn-xs"
@click="showResolvedList = !showResolvedList"
>
{{ showResolvedList ? 'Masquer résolus' : `Voir résolus (${resolvedComments.length})` }}
</button>
</div>
<!-- Formulaire d'ajout -->
<div class="flex gap-2">
<textarea
v-model="newContent"
class="textarea textarea-bordered flex-1 text-sm"
rows="2"
placeholder="Ajouter un commentaire..."
:disabled="submitting"
@keydown.ctrl.enter="handleSubmit"
/>
<button
type="button"
class="btn btn-primary btn-sm self-end"
: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>
<!-- Liste des commentaires ouverts -->
<div v-if="loadingComments" class="flex justify-center py-4">
<span class="loading loading-spinner loading-sm" />
</div>
<div v-else-if="openComments.length === 0" class="text-sm text-base-content/50 py-2">
Aucun commentaire ouvert.
</div>
<div v-else class="space-y-3">
<div
v-for="comment in openComments"
:key="comment.id"
class="bg-base-200 rounded-lg p-3 space-y-2"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
</div>
</div>
<div class="flex items-center justify-between text-xs text-base-content/60">
<span>
{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}
</span>
<div v-if="canEdit" class="flex gap-1">
<button
type="button"
class="btn btn-success btn-xs gap-1"
:disabled="loading"
@click="handleResolve(comment.id)"
>
<IconLucideCheck class="w-3 h-3" />
Résoudre
</button>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loading"
@click="handleDelete(comment.id)"
>
<IconLucideTrash2 class="w-3 h-3" />
</button>
</div>
</div>
</div>
</div>
<!-- Commentaires résolus -->
<div v-if="showResolvedList && resolvedComments.length" class="space-y-2">
<div class="divider text-xs text-base-content/40">
Résolus
</div>
<div
v-for="comment in resolvedComments"
:key="comment.id"
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
>
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
<div class="flex items-center justify-between text-xs text-base-content/50">
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
<span v-if="comment.resolvedByName">
Résolu par {{ comment.resolvedByName }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useComments, type Comment } from '~/composables/useComments'
import { usePermissions } from '~/composables/usePermissions'
import IconLucideMessageSquare from '~icons/lucide/message-square'
import IconLucideSend from '~icons/lucide/send'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideTrash2 from '~icons/lucide/trash-2'
const props = defineProps<{
entityType: string
entityId: string
entityName?: string
showResolved?: boolean
}>()
const { canEdit } = usePermissions()
const {
loading,
fetchComments,
createComment,
resolveComment,
deleteComment,
} = useComments()
const comments = ref<Comment[]>([])
const newContent = ref('')
const submitting = ref(false)
const loadingComments = ref(false)
const showResolvedList = ref(false)
const openComments = computed(() =>
comments.value.filter(c => c.status === 'open'),
)
const resolvedComments = computed(() =>
comments.value.filter(c => c.status === 'resolved'),
)
const formatCommentDate = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
const loadComments = async () => {
loadingComments.value = true
const [openResult, resolvedResult] = await Promise.all([
fetchComments(props.entityType, props.entityId, 'open'),
props.showResolved
? fetchComments(props.entityType, props.entityId, 'resolved')
: Promise.resolve({ success: true, data: [] as Comment[] }),
])
const open = openResult.success ? (openResult.data ?? []) : []
const resolved = resolvedResult.success ? (resolvedResult.data ?? []) : []
comments.value = [...open, ...resolved]
loadingComments.value = false
}
const handleSubmit = async () => {
const content = newContent.value.trim()
if (!content) return
submitting.value = true
const result = await createComment(
props.entityType,
props.entityId,
content,
props.entityName,
)
submitting.value = false
if (result.success) {
newContent.value = ''
await loadComments()
}
}
const handleResolve = async (commentId: string) => {
const result = await resolveComment(commentId)
if (result.success) {
await loadComments()
}
}
const handleDelete = async (commentId: string) => {
const result = await deleteComment(commentId)
if (result.success) {
comments.value = comments.value.filter(c => c.id !== commentId)
}
}
onMounted(() => {
if (props.entityId) {
loadComments()
}
})
</script>

View File

@@ -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,315 +1,200 @@
<template> <template>
<div class="space-y-4"> <div>
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="componentDocuments"
@close="closePreview" @close="closePreview"
/> />
<!-- 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 <span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span>
v-if="component.skeletonOnly" </div>
class="badge badge-warning badge-sm" <div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
> <span
Défini dans le catalogue v-for="constructeur in componentConstructeursDisplay"
</span> :key="constructeur.id"
<span v-if="component.reference" class="badge badge-outline badge-sm">{{ component.reference }}</span> class="text-xs text-base-content/50"
<template v-if="componentConstructeursDisplay.length"> >
<span {{ constructeur.name }}
v-for="constructeur in componentConstructeursDisplay" </span>
:key="constructeur.id" <span v-if="displayProductName" class="badge badge-info badge-xs">
class="badge badge-outline badge-sm" {{ displayProductName }}
> </span>
{{ constructeur.name }}
</span>
</template>
<span v-if="component.prix" class="badge badge-primary badge-sm">{{ component.prix }}</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
<span
v-if="component.typeMachineComponentRequirement"
class="badge badge-outline badge-sm"
>
Groupe : {{ component.typeMachineComponentRequirement.label || component.typeMachineComponentRequirement.typeComposant?.name || 'Non défini' }}
</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.path"
:src="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="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}/edit`"
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
@@ -320,88 +205,27 @@
@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" :delete-disabled="uploadingDocuments"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2" empty-text="Aucun document lié à ce composant."
> @preview="openPreview"
<div class="flex items-center gap-3 text-sm"> @delete="removeDocument"
<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.path"
:src="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 && !piece.skeletonOnly" :is-edit-mode="isEditMode"
@update="updatePiece" @update="updatePiece"
@edit="editPiece" @edit="editPiece"
@custom-field-update="updatePieceCustomField" @custom-field-update="updatePieceCustomField"
@@ -409,17 +233,32 @@
</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"
:component="subComponent" :component="subComponent"
:is-edit-mode="isEditMode && !subComponent.skeletonOnly" :is-edit-mode="isEditMode"
:collapse-all="collapseAll" :collapse-all="collapseAll"
:toggle-token="toggleToken" :toggle-token="toggleToken"
@update="$emit('update', $event)" @update="$emit('update', $event)"
@@ -450,19 +289,9 @@ 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'
@@ -470,11 +299,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 {
@@ -526,6 +356,14 @@ 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()

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

@@ -134,76 +134,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 +184,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 +247,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,116 @@
<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="name"
:disabled="disabled"
@update:modelValue="updateValue"
>
<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, 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 { composants, loading, loadComposants } = useComposants()
const composantOptions = computed(() => {
const baseOptions = Array.isArray(composants.value) ? composants.value : []
if (!props.typeComposantId) {
return baseOptions
}
const allowedTypeId = String(props.typeComposantId)
return baseOptions.filter((composant: any) => {
const typeId =
composant?.typeComposantId ||
composant?.typeComposant?.id ||
null
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => {
if (composantOptions.value.length === 0) {
loadComposants({ itemsPerPage: 200 }).catch((error: unknown) => {
console.error('Erreur lors du chargement des composants:', error)
})
}
})
watch(
() => props.modelValue,
(value) => {
if (typeof value === 'string' && value) {
const exists = composantOptions.value.some((c: any) => c.id === value)
if (!exists && !loading.value) {
loadComposants({ itemsPerPage: 200, force: true }).catch((error: unknown) => {
console.error('Erreur lors du chargement des composants:', error)
})
}
}
},
)
const updateValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === '') {
emit('update:modelValue', null)
return
}
emit('update:modelValue', String(value))
}
const formatDescription = (option: any) => {
const parts: string[] = []
if (option?.reference) {
parts.push(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

@@ -20,16 +20,16 @@
</button> </button>
<div <div
v-if="openDropdown" v-if="openDropdown"
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col" class="absolute z-20 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
> >
<div <div
v-if="options.length === 0" v-if="filteredOptions.length === 0"
class="px-3 py-2 text-xs text-gray-500" class="px-3 py-2 text-xs text-gray-500"
> >
Aucun fournisseur trouvé Aucun fournisseur trouvé
</div> </div>
<button <button
v-for="option in options" v-for="option in filteredOptions"
:key="option.id" :key="option.id"
type="button" type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none" class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@@ -164,8 +164,7 @@ const openCreateModal = ref(false)
const creating = ref(false) const creating = ref(false)
const options = ref<ConstructeurSummary[]>([]) const options = ref<ConstructeurSummary[]>([])
const selectedIds = ref<string[]>([]) const selectedIds = ref<string[]>([])
let searchTimeout: ReturnType<typeof setTimeout> | null = null
let lastSearchTerm = ''
const uniqueOptions = (items: ConstructeurSummary[] = []) => { const uniqueOptions = (items: ConstructeurSummary[] = []) => {
const seen = new Map<string, ConstructeurSummary>() const seen = new Map<string, ConstructeurSummary>()
@@ -182,32 +181,22 @@ const normalizedInitialOptions = computed(() =>
) )
const applyOptions = (items: ConstructeurSummary[] = []) => { const applyOptions = (items: ConstructeurSummary[] = []) => {
const normalized = uniqueOptions([ options.value = uniqueOptions([
...normalizedInitialOptions.value, ...normalizedInitialOptions.value,
...items, ...items,
]) ])
const limited = normalized.slice(0, 10)
selectedIds.value.forEach((id) => {
if (!limited.some((item) => item.id === id)) {
const match =
normalized.find((item) => item.id === id) ||
constructeurs.value.find((item) => item.id === id)
if (match) {
if (limited.length >= 10) {
limited.pop()
}
limited.unshift(match)
}
}
})
options.value = uniqueOptions([
...normalizedInitialOptions.value,
...limited,
])
} }
const filteredOptions = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
if (!term) return options.value
return options.value.filter((option) =>
(option.name ?? '').toLowerCase().includes(term)
|| (option.email && option.email.toLowerCase().includes(term))
|| (option.phone && option.phone.toLowerCase().includes(term))
)
})
const createForm = ref({ const createForm = ref({
name: '', name: '',
email: '', email: '',
@@ -257,46 +246,20 @@ const extractDataArray = (data: unknown): ConstructeurSummary[] => {
} }
const ensureOptionsLoaded = async (force = false) => { const ensureOptionsLoaded = async (force = false) => {
if (!force && !searchTerm.value && constructeurs.value.length) { if (!force && constructeurs.value.length) {
applyOptions(constructeurs.value as ConstructeurSummary[]) applyOptions(constructeurs.value as ConstructeurSummary[])
return return
} }
if (!force && searchTerm.value === lastSearchTerm && options.value.length) { const result = await searchConstructeurs('')
return
}
if (options.value.length && !force) {
return
}
const result = await searchConstructeurs(searchTerm.value)
if (result.success) { if (result.success) {
applyOptions(extractDataArray(result.data)) applyOptions(extractDataArray(result.data))
lastSearchTerm = searchTerm.value
} }
} }
const onSearch = () => { const onSearch = () => {
openDropdown.value = true openDropdown.value = true
if (searchTimeout) { ensureOptionsLoaded()
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(async () => {
if (!searchTerm.value && constructeurs.value.length) {
applyOptions(constructeurs.value as ConstructeurSummary[])
lastSearchTerm = ''
return
}
if (searchTerm.value === lastSearchTerm) {
return
}
const result = await searchConstructeurs(searchTerm.value)
if (result.success) {
applyOptions(extractDataArray(result.data))
lastSearchTerm = searchTerm.value
}
}, 250)
} }
const toggleOption = (option: ConstructeurSummary) => { const toggleOption = (option: ConstructeurSummary) => {
@@ -319,9 +282,19 @@ const closeCreateModal = () => {
} }
const handleCreate = async () => { const handleCreate = async () => {
const trimmedName = createForm.value.name.trim()
const duplicate = options.value.find(
(o) => (o.name ?? '').toLowerCase() === trimmedName.toLowerCase(),
)
if (duplicate) {
emitSelection([...selectedIds.value, duplicate.id])
closeCreateModal()
return
}
creating.value = true creating.value = true
const payload: { name: string; email?: string; phone?: string } = { const payload: { name: string; email?: string; phone?: string } = {
name: createForm.value.name, name: trimmedName,
} }
if (createForm.value.email) { if (createForm.value.email) {
payload.email = createForm.value.email payload.email = createForm.value.email
@@ -383,9 +356,6 @@ watch(
constructeurs, constructeurs,
(list) => { (list) => {
applyOptions((list as ConstructeurSummary[]) || []) applyOptions((list as ConstructeurSummary[]) || [])
if (!searchTerm.value) {
lastSearchTerm = ''
}
}, },
{ immediate: true }, { immediate: true },
) )
@@ -405,9 +375,6 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', clickHandler) window.removeEventListener('click', clickHandler)
if (searchTimeout) {
clearTimeout(searchTimeout)
}
}) })
watch( watch(

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">
@@ -55,16 +55,16 @@
</select> </select>
<!-- Champ de type BOOLEAN --> <!-- Champ de type BOOLEAN -->
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="fieldValues[field.id]" v-model="fieldValues[field.id]"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="toggle toggle-primary toggle-sm"
:checked="fieldValues[field.id] === 'true'" :checked="fieldValues[field.id] === 'true'"
@change="updateCustomFieldValue(field.id)" @change="updateCustomFieldValue(field.id)"
> >
<span class="text-sm">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span> <span class="text-sm" :class="fieldValues[field.id] === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
</div> </label>
<!-- Champ de type DATE --> <!-- Champ de type DATE -->
<input <input

View File

@@ -10,9 +10,12 @@
<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-base-content/50">
{{ activeIndex + 1 }} / {{ navTotal }}
</span>
</h3> </h3>
<p class="text-sm text-gray-500 truncate"> <p class="text-sm text-base-content/50 truncate">
{{ document?.name || document?.filename }}<span v-if="documentDescription"> {{ documentDescription }}</span> {{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> &bull; {{ documentDescription }}</span>
</p> </p>
</div> </div>
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close"> <button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
@@ -20,15 +23,35 @@
</button> </button>
</header> </header>
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden"> <section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden relative">
<button
v-if="hasPrev"
type="button"
class="absolute left-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
title="Document précédent (←)"
@click="goToPrev"
>
</button>
<button
v-if="hasNext"
type="button"
class="absolute right-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
title="Document suivant (→)"
@click="goToNext"
>
</button>
<div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden"> <div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden">
<template v-if="previewType === 'image'"> <template v-if="previewType === 'image'">
<img :src="document?.path" alt="preview" class="max-h-full max-w-full object-contain"> <img :src="documentSrc" alt="preview" class="max-h-full max-w-full object-contain">
</template> </template>
<template v-else-if="previewType === 'pdf'"> <template v-else-if="previewType === 'pdf'">
<iframe <iframe
:src="document?.path" :src="documentSrc"
class="w-full h-full bg-white" class="w-full h-full bg-white"
frameborder="0" frameborder="0"
title="Aperçu PDF" title="Aperçu PDF"
@@ -36,16 +59,16 @@
</template> </template>
<template v-else-if="previewType === 'audio'"> <template v-else-if="previewType === 'audio'">
<audio :src="document?.path" controls class="w-full" /> <audio :src="documentSrc" controls class="w-full" />
</template> </template>
<template v-else-if="previewType === 'video'"> <template v-else-if="previewType === 'video'">
<video :src="document?.path" controls class="w-full h-full bg-black" /> <video :src="documentSrc" controls class="w-full h-full bg-black" />
</template> </template>
<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>
@@ -59,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>
@@ -80,31 +103,110 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { getPreviewType, describeDocument } from '~/utils/documentPreview' import { getPreviewType, describeDocument, canPreviewDocument } from '~/utils/documentPreview'
const props = defineProps({ const props = defineProps({
document: { document: {
type: Object, type: Object,
default: null default: null,
}, },
visible: { visible: {
type: Boolean, type: Boolean,
default: false default: false,
} },
documents: {
type: Array,
default: () => [],
},
}) })
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
const previewType = computed(() => getPreviewType(props.document)) // --- Carousel navigation ---
const documentDescription = computed(() => describeDocument(props.document))
const previewableDocuments = computed(() => {
if (!props.documents?.length) return []
return props.documents.filter((doc) => canPreviewDocument(doc))
})
const navTotal = computed(() => previewableDocuments.value.length)
const activeIndex = ref(0)
// Sync index when the parent changes the document prop (e.g. user clicks a different "Consulter")
watch(
() => props.document,
(doc) => {
if (!doc || !previewableDocuments.value.length) {
activeIndex.value = 0
return
}
const idx = previewableDocuments.value.findIndex((d) => d.id === doc.id)
activeIndex.value = idx >= 0 ? idx : 0
},
{ immediate: true },
)
const activeDoc = computed(() => {
if (previewableDocuments.value.length && activeIndex.value < previewableDocuments.value.length) {
return previewableDocuments.value[activeIndex.value]
}
return props.document
})
const hasPrev = computed(() => navTotal.value > 1 && activeIndex.value > 0)
const hasNext = computed(() => navTotal.value > 1 && activeIndex.value < navTotal.value - 1)
const goToPrev = () => {
if (hasPrev.value) activeIndex.value--
}
const goToNext = () => {
if (hasNext.value) activeIndex.value++
}
// Keyboard navigation
const handleKeydown = (e) => {
if (!props.visible) return
if (e.key === 'ArrowLeft') {
e.preventDefault()
goToPrev()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
goToNext()
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
watch(
() => props.visible,
(val) => {
if (val) {
document.addEventListener('keydown', handleKeydown)
} else {
document.removeEventListener('keydown', handleKeydown)
}
},
)
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// --- Preview logic (uses activeDoc) ---
const previewType = computed(() => getPreviewType(activeDoc.value))
const documentDescription = computed(() => describeDocument(activeDoc.value))
const documentSrc = computed(() => activeDoc.value?.fileUrl || activeDoc.value?.path || '')
const textContent = ref('') const textContent = ref('')
const textLoading = ref(false) const textLoading = ref(false)
const textError = ref('') const textError = ref('')
watch( watch(
() => props.document, activeDoc,
async (doc) => { async (doc) => {
textContent.value = '' textContent.value = ''
textError.value = '' textError.value = ''
@@ -115,22 +217,17 @@ watch(
try { try {
textLoading.value = true textLoading.value = true
const path = doc.path || '' const url = doc.fileUrl || doc.path || ''
if (path.startsWith('data:')) { if (!url) {
const base64Part = path.split(',')[1] || '' textError.value = 'Aucune URL de document disponible.'
if (!base64Part) { return
textError.value = 'Impossible de lire ce document texte.'
return
}
const decoded = atob(base64Part)
textContent.value = decodeURIComponent(escape(decoded))
} else {
const response = await fetch(path)
if (!response.ok) {
throw new Error('Téléchargement du document impossible')
}
textContent.value = await response.text()
} }
const response = await fetch(url, { credentials: 'include' })
if (!response.ok) {
throw new Error('Téléchargement du document impossible')
}
textContent.value = await response.text()
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement du texte:', error) console.error('Erreur lors du chargement du texte:', error)
textError.value = error.message || 'Impossible de lire ce document.' textError.value = error.message || 'Impossible de lire ce document.'
@@ -138,7 +235,7 @@ watch(
textLoading.value = false textLoading.value = false
} }
}, },
{ immediate: true } { immediate: true },
) )
const close = () => { const close = () => {
@@ -146,11 +243,8 @@ const close = () => {
} }
const download = () => { const download = () => {
if (!props.document?.path) { return } const url = activeDoc.value?.downloadUrl || activeDoc.value?.fileUrl || activeDoc.value?.path
const link = document.createElement('a') if (!url) { return }
link.href = props.document.path window.open(url, '_blank')
link.download = props.document.filename || props.document.name || 'document'
link.target = '_blank'
link.click()
} }
</script> </script>

View File

@@ -40,6 +40,8 @@ type GenericDocument = {
filename?: string | null; filename?: string | null;
mimeType?: string | null; mimeType?: string | null;
path?: string | null; path?: string | null;
fileUrl?: string | null;
downloadUrl?: string | null;
size?: number | null; size?: number | null;
}; };
@@ -52,7 +54,7 @@ const normalizedDocument = computed(() => props.document ?? null);
const canRenderImage = computed(() => { const canRenderImage = computed(() => {
const doc = normalizedDocument.value; const doc = normalizedDocument.value;
return !!(doc && isImageDocument(doc) && doc.path); return !!(doc && isImageDocument(doc) && (doc.fileUrl || doc.path));
}); });
const canRenderPdf = computed(() => { const canRenderPdf = computed(() => {
@@ -73,13 +75,14 @@ const appendPdfViewerParams = (src: string) => {
const previewSrc = computed(() => { const previewSrc = computed(() => {
const doc = normalizedDocument.value; const doc = normalizedDocument.value;
if (!doc || !doc.path) { const url = doc?.fileUrl || doc?.path;
if (!doc || !url) {
return ''; return '';
} }
if (isPdfDocument(doc)) { if (isPdfDocument(doc)) {
return appendPdfViewerParams(doc.path); return appendPdfViewerParams(url);
} }
return doc.path; return url;
}); });
const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16')); const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16'));

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
@@ -54,7 +54,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)">
@@ -130,7 +130,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

@@ -1,63 +1,91 @@
<template> <template>
<div class="border border-gray-200 rounded-lg p-4"> <div class="space-y-4">
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview" @close="closePreview"
/> />
<div class="flex items-center justify-between mb-3"> <!-- Piece Header (collapsible, same pattern as ComponentItem) -->
<div class="flex items-center gap-2"> <div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
<IconLucidePackage class="w-4 h-4 text-purple-500" aria-hidden="true" /> <div class="flex items-start gap-3 flex-1 min-w-0">
<input <button
v-if="isEditMode" type="button"
:id="`piece-name-${piece.id}`" class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
v-model="pieceData.name" :class="{ 'rotate-90': !isCollapsed }"
type="text" :aria-expanded="!isCollapsed"
class="font-semibold text-lg input input-sm input-bordered" :title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
@blur="updatePiece" @click="toggleCollapse"
/>
<div
v-else
class="font-semibold text-lg input input-sm input-bordered bg-base-200"
> >
{{ pieceData.name }} <IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
</button>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold">
{{ pieceData.name }}
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
</h3>
<div class="flex flex-wrap gap-2 mt-2">
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
Rattachée à {{ piece.parentComponentName }}
</span>
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
<template v-if="pieceConstructeursDisplay.length">
<span
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="badge badge-outline badge-sm"
>
{{ constructeur.name }}
</span>
</template>
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-2 text-xs"> <button
<span v-if="showDelete"
v-if="piece.skeletonOnly" type="button"
class="badge badge-warning badge-sm" class="btn btn-ghost btn-xs text-error shrink-0"
> title="Supprimer cette pièce"
Défini dans le catalogue @click="$emit('delete')"
</span> >
<span Supprimer
v-if="piece.typeMachinePieceRequirement" </button>
class="badge badge-outline badge-sm"
>
Groupe :
{{
piece.typeMachinePieceRequirement.label ||
piece.typeMachinePieceRequirement.typePiece?.name ||
"Non défini"
}}
</span>
<span
v-if="piece.parentComponentName"
class="badge badge-ghost badge-sm"
>
Rattachée à {{ piece.parentComponentName }}
</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div>
</div> </div>
<div class="space-y-2 text-sm"> <div v-show="!isCollapsed" class="space-y-4">
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
<div class="space-y-2 text-sm">
<div v-if="isEditMode" class="form-control">
<label class="label">
<span class="label-text text-sm">Quantité</span>
</label>
<input
v-model.number="pieceData.quantity"
type="number"
min="1"
step="1"
class="input input-bordered input-sm md:input-md w-24"
@blur="updatePiece"
/>
</div>
<div v-else-if="displayQuantity > 1">
<span class="font-medium">Quantité:</span>
<span class="ml-2">{{ displayQuantity }}</span>
</div>
<div> <div>
<span class="font-medium">Référence:</span> <span class="font-medium">Référence:</span>
<input <input
@@ -86,7 +114,7 @@
</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>
@@ -169,65 +197,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.path"
:src="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
@@ -235,149 +208,19 @@
</div> </div>
</div> </div>
</div> </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"
@@ -389,7 +232,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>
@@ -401,95 +244,26 @@
@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" :delete-disabled="uploadingDocuments"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2" empty-text="Aucun document lié à cette pièce."
> @preview="openPreview"
<div class="flex items-center gap-3 text-sm"> @delete="removeDocument"
<div />
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center" </div>
:class="documentThumbnailClass(document)"
>
<img
v-if="isImageDocument(document) && document.path"
:src="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>
</template> </template>
<script setup> <script setup>
import { reactive, onMounted, watch, computed } from 'vue' import { reactive, ref, onMounted, watch, computed } from 'vue'
import ConstructeurSelect from './ConstructeurSelect.vue' import ConstructeurSelect from './ConstructeurSelect.vue'
import ProductSelect from '~/components/ProductSelect.vue' import ProductSelect from '~/components/ProductSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucidePackage from '~icons/lucide/package' 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 {
@@ -498,22 +272,8 @@ import {
uniqueConstructeurIds, uniqueConstructeurIds,
} 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'
@@ -522,9 +282,12 @@ 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 },
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({
@@ -532,6 +295,11 @@ const pieceData = reactive({
reference: props.piece.reference || '', reference: props.piece.reference || '',
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 ---
@@ -575,6 +343,23 @@ const {
updateCustomField, updateCustomField,
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' }) } = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
// --- Collapse state ---
const isCollapsed = ref(true)
watch(
() => props.toggleToken,
() => {
isCollapsed.value = props.collapseAll
if (!isCollapsed.value) refreshDocuments()
},
{ immediate: true },
)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
if (!isCollapsed.value) refreshDocuments()
}
// --- Constructeurs --- // --- Constructeurs ---
const { constructeurs } = useConstructeurs() const { constructeurs } = useConstructeurs()
@@ -648,16 +433,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) {
@@ -675,13 +460,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,
@@ -721,11 +507,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
}, },
) )
@@ -733,6 +520,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,116 @@
<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="name"
:disabled="disabled"
@update:modelValue="updateValue"
>
<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, 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 { pieces, loading, loadPieces } = usePieces()
const pieceOptions = computed(() => {
const baseOptions = Array.isArray(pieces.value) ? pieces.value : []
if (!props.typePieceId) {
return baseOptions
}
const allowedTypeId = String(props.typePieceId)
return baseOptions.filter((piece: any) => {
const typeId =
piece?.typePieceId ||
piece?.typePiece?.id ||
null
return typeId ? String(typeId) === allowedTypeId : false
})
})
onMounted(() => {
if (pieceOptions.value.length === 0) {
loadPieces({ itemsPerPage: 200 }).catch((error: unknown) => {
console.error('Erreur lors du chargement des pièces:', error)
})
}
})
watch(
() => props.modelValue,
(value) => {
if (typeof value === 'string' && value) {
const exists = pieceOptions.value.some((piece: any) => piece.id === value)
if (!exists && !loading.value) {
loadPieces({ itemsPerPage: 200, force: true }).catch((error: unknown) => {
console.error('Erreur lors du chargement des pièces:', error)
})
}
}
},
)
const updateValue = (value: string | number | null | undefined) => {
if (value === undefined || value === null || value === '') {
emit('update:modelValue', null)
return
}
emit('update:modelValue', String(value))
}
const formatDescription = (option: any) => {
const parts: string[] = []
if (option?.reference) {
parts.push(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

@@ -81,10 +81,10 @@ onMounted(() => {
watch( watch(
() => props.modelValue, () => props.modelValue,
(value) => { (value) => {
if (typeof value === 'string') { if (typeof value === 'string' && value) {
const exists = productOptions.value.some((product) => product.id === value) const exists = productOptions.value.some((product) => product.id === value)
if (!exists && productOptions.value.length === 0 && !loading.value) { if (!exists && !loading.value) {
loadProducts().catch((error) => { loadProducts({ force: true }).catch((error) => {
console.error('Erreur lors du chargement des produits:', error) console.error('Erreur lors du chargement des produits:', error)
}) })
} }

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

@@ -24,7 +24,7 @@
class="w-4 h-4" class="w-4 h-4"
aria-hidden="true" aria-hidden="true"
/> />
<IconLucideX <IconLucideCircleX
v-else-if="toast.type === 'error'" v-else-if="toast.type === 'error'"
class="w-4 h-4" class="w-4 h-4"
aria-hidden="true" aria-hidden="true"
@@ -64,6 +64,7 @@
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import IconLucideCheck from '~icons/lucide/check' import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x' import IconLucideX from '~icons/lucide/x'
import IconLucideCircleX from '~icons/lucide/circle-x'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle' import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
import IconLucideInfo from '~icons/lucide/info' import IconLucideInfo from '~icons/lucide/info'

View File

@@ -1,34 +0,0 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="card-actions justify-end">
<button type="button" class="btn btn-outline" @click="$emit('reset')">
Réinitialiser
</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
<IconLucideRefreshCw
v-if="saving"
class="w-5 h-5 mr-2 animate-spin"
aria-hidden="true"
/>
<IconLucideCheck v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ saving ? 'Sauvegarde...' : 'Sauvegarder les modifications' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import IconLucideRefreshCw from '~icons/lucide/refresh-cw'
import IconLucideCheck from '~icons/lucide/check'
defineProps({
saving: {
type: Boolean,
default: false
}
})
defineEmits(['reset'])
</script>

View File

@@ -1,105 +0,0 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
Informations de base
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du type</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
v-model="nameModel"
type="text"
placeholder="Nom du type de machine"
class="input input-bordered"
required
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie</span>
</label>
<input
v-model="categoryModel"
type="text"
placeholder="Catégorie du type"
class="input input-bordered"
>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="descriptionModel"
placeholder="Description du type de machine"
class="textarea textarea-bordered h-24"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fréquence de maintenance</span>
</label>
<input
v-model="maintenanceModel"
type="text"
placeholder="ex: Mensuelle, Trimestrielle"
class="input input-bordered"
>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
name: {
type: String,
default: ''
},
category: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
maintenanceFrequency: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:name', 'update:category', 'update:description', 'update:maintenanceFrequency'])
const nameModel = computed({
get: () => props.name,
set: value => emit('update:name', value)
})
const categoryModel = computed({
get: () => props.category,
set: value => emit('update:category', value)
})
const descriptionModel = computed({
get: () => props.description,
set: value => emit('update:description', value)
})
const maintenanceModel = computed({
get: () => props.maintenanceFrequency,
set: value => emit('update:maintenanceFrequency', value)
})
</script>

View File

@@ -1,95 +0,0 @@
<template>
<RequirementListEditor
v-model="requirements"
:type-options="componentTypes"
type-field="typeComposantId"
:labels="labels"
:default-requirement="createDefaultRequirement"
:required-fallback="true"
:min-fallback="1"
:type-loading="loadingComponentTypes"
/>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
type Requirement = Record<string, unknown> & {
id?: string | number
typeComposantId?: string | number | null
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper: string
labelPlaceholder: string
minLabel: string
maxLabel: string
maxHelper: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array as () => Requirement[],
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value: Requirement[]) => emit('update:modelValue', value),
})
const createDefaultRequirement = (): Requirement => ({
id: undefined,
typeComposantId: null,
label: '',
minCount: 1,
maxCount: null,
required: true,
allowNewModels: true,
})
const labels: Labels = {
headerTitle: 'Familles de composants',
addButton: 'Ajouter une famille',
description:
"Chaque ligne correspond à un groupe de composants attendus pour le type de machine. Sélectionnez le type de composant (famille), puis définissez le nombre minimal/maximal et si l'utilisateur peut créer un nouveau modèle lors de l'instanciation d'une machine.",
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de composants.',
typeSelectLabel: 'Type de composant',
typePlaceholder: 'Sélectionner un type',
labelFieldLabel: 'Libellé',
labelFieldHelper: 'Optionnel',
labelPlaceholder: 'Ex: Sangles principales',
minLabel: 'Minimum requis',
maxLabel: 'Maximum autorisé',
maxHelper: 'Laisser vide pour illimité',
requiredLabel: 'Requis',
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
}
onMounted(async () => {
if (!componentTypes.value.length) {
await loadComponentTypes()
}
})
</script>

View File

@@ -1,356 +0,0 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-sm p-1"
@click="toggleSection"
>
<IconLucideChevronRight
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': expanded }"
aria-hidden="true"
/>
</button>
<h3 class="card-title text-lg">
Champs personnalisés du type
</h3>
<span class="badge badge-primary">{{ fields.length }}</span>
</div>
</div>
<div v-if="expanded" class="space-y-4">
<div
v-for="(field, fieldIndex) in fields"
:key="field.id || field.customFieldId || field.__key || `field-${fieldIndex}`"
class="border border-gray-200 rounded-lg p-4 bg-gray-50 transition-colors"
:class="fieldReorderClass(fieldIndex)"
draggable="true"
@dragstart="onFieldDragStart(fieldIndex, $event)"
@dragenter="onFieldDragEnter(fieldIndex)"
@dragover.prevent
@drop="onFieldDrop(fieldIndex)"
@dragend="onFieldDragEnd"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
title="Réorganiser"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="btn btn-ghost btn-xs p-1"
title="Plier / déplier le champ"
@click="toggleField(fieldIndex)"
>
<IconLucideChevronRight
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': isFieldExpanded(fieldIndex) }"
aria-hidden="true"
/>
</button>
<IconLucideListChecks class="w-4 h-4 text-blue-500" aria-hidden="true" />
<h5 class="text-sm font-medium">
Champ personnalisé {{ fieldIndex + 1 }}
</h5>
<span v-if="!isFieldExpanded(fieldIndex)" class="text-xs text-gray-500 truncate max-w-[160px]">
{{ field.name || 'Sans nom' }}
</span>
</div>
<button
type="button"
class="btn btn-square btn-error btn-sm"
title="Supprimer ce champ"
@click="removeField(fieldIndex)"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div v-if="isFieldExpanded(fieldIndex)" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du champ</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
:value="field.name"
type="text"
placeholder="Nom du champ"
class="input input-bordered input-sm"
required
@input="updateField(fieldIndex, { name: $event.target.value })"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Type de champ</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
class="select select-bordered select-sm"
required
:value="field.type"
@change="updateField(fieldIndex, { type: $event.target.value })"
>
<option value="">
Sélectionner un type
</option>
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste déroulante
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
</div>
<div v-if="isFieldExpanded(fieldIndex)" class="mt-3">
<div class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="field.required"
@change="updateField(fieldIndex, { required: $event.target.checked })"
>
<span class="text-sm">Champ obligatoire</span>
</div>
</div>
<div
v-if="isFieldExpanded(fieldIndex) && field.type === 'select'"
class="mt-3"
>
<label class="label">
<span class="label-text">Options de la liste</span>
<span class="label-text-alt">Une option par ligne</span>
</label>
<textarea
:value="field.optionsText || ''"
placeholder="Option 1&#10;Option 2&#10;Option 3"
class="textarea textarea-bordered textarea-sm w-full h-20"
@input="updateOptions(fieldIndex, $event.target.value)"
/>
</div>
</div>
<div class="flex justify-end">
<button type="button" class="btn btn-primary btn-sm" @click="addField">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter un champ
</button>
</div>
</div>
<div v-else class="flex justify-end">
<button type="button" class="btn btn-primary btn-sm" @click="addField">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter un champ
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideListChecks from '~icons/lucide/list-checks'
import IconLucideX from '~icons/lucide/x'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
allExpanded: {
type: Boolean,
default: false
},
expandAllTrigger: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update:modelValue'])
const fields = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const expanded = ref(false)
const expandedFields = ref([])
const draggingFieldIndex = ref(null)
const fieldDropTargetIndex = ref(null)
const applyOrderIndex = (list = []) => {
if (!Array.isArray(list)) { return [] }
list.forEach((field, index) => {
if (field && typeof field === 'object') {
field.orderIndex = index
if (typeof field.__key !== 'string' || !field.__key) {
field.__key = createFieldKey()
}
}
})
return list
}
const createEmptyField = () => ({
name: '',
type: '',
required: false,
optionsText: '',
orderIndex: fields.value.length,
__key: createFieldKey()
})
const resetDragState = () => {
draggingFieldIndex.value = null
fieldDropTargetIndex.value = null
}
const reorderFields = (from, to) => {
const list = Array.isArray(fields.value) ? fields.value.slice() : []
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
list.splice(to, 0, moved)
if (Array.isArray(expandedFields.value)) {
const expandedCopy = expandedFields.value.slice()
const [expandedState] = expandedCopy.splice(from, 1)
expandedCopy.splice(to, 0, expandedState)
expandedFields.value = expandedCopy
}
fields.value = applyOrderIndex(list)
resetDragState()
}
watch(
() => props.expandAllTrigger,
() => {
expanded.value = props.allExpanded
expandedFields.value = fields.value.map(() => props.allExpanded)
},
{ immediate: true }
)
watch(
() => fields.value.length,
(length) => {
expandedFields.value = Array.from({ length }, (_, index) => expandedFields.value[index] ?? props.allExpanded)
}
)
const toggleSection = () => {
expanded.value = !expanded.value
}
const ensureFieldState = (index) => {
if (expandedFields.value[index] === undefined) {
expandedFields.value[index] = props.allExpanded
}
}
const isFieldExpanded = (index) => {
ensureFieldState(index)
return expandedFields.value[index]
}
const toggleField = (index) => {
ensureFieldState(index)
expandedFields.value[index] = !expandedFields.value[index]
}
const addField = () => {
const next = Array.isArray(fields.value) ? fields.value.slice() : []
next.push(createEmptyField())
fields.value = applyOrderIndex(next)
expandedFields.value.push(true)
expanded.value = true
}
const removeField = (index) => {
const next = Array.isArray(fields.value)
? fields.value.filter((_, i) => i !== index)
: []
fields.value = applyOrderIndex(next)
expandedFields.value.splice(index, 1)
}
const updateField = (index, patch) => {
const next = Array.isArray(fields.value) ? fields.value.slice() : []
next[index] = { ...next[index], ...patch }
fields.value = applyOrderIndex(next)
}
const updateOptions = (index, value) => {
updateField(index, {
optionsText: value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
})
}
const onFieldDragStart = (index, event) => {
draggingFieldIndex.value = index
fieldDropTargetIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onFieldDragEnter = (index) => {
if (draggingFieldIndex.value === null) { return }
fieldDropTargetIndex.value = index
}
const onFieldDrop = (index) => {
if (draggingFieldIndex.value === null) {
resetDragState()
return
}
reorderFields(draggingFieldIndex.value, index)
}
const onFieldDragEnd = () => {
resetDragState()
}
const fieldReorderClass = (index) => {
if (draggingFieldIndex.value === index) {
return 'border-dashed border-primary'
}
if (
draggingFieldIndex.value !== null &&
fieldDropTargetIndex.value === index &&
draggingFieldIndex.value !== index
) {
return 'border-primary border-dashed bg-primary/5'
}
return ''
}
</script>

View File

@@ -1,161 +0,0 @@
<template>
<form class="space-y-6" @submit.prevent="handleSubmit">
<TypeEditToolbar :all-expanded="allExpanded" @toggle="toggleAllSections" />
<TypeEditBaseInfoSection
v-model:name="formData.name"
v-model:category="formData.category"
v-model:description="formData.description"
v-model:maintenance-frequency="formData.maintenanceFrequency"
/>
<TypeEditCustomFieldsSection
:model-value="formData.customFields"
:all-expanded="allExpanded"
:expand-all-trigger="expandAllTrigger"
@update:model-value="(value) => (formData.customFields = value)"
/>
<TypeEditComponentRequirementsSection
:model-value="formData.componentRequirements"
@update:model-value="(value) => (formData.componentRequirements = value)"
/>
<TypeEditPieceRequirementsSection
:model-value="formData.pieceRequirements"
@update:model-value="(value) => (formData.pieceRequirements = value)"
/>
<TypeEditProductRequirementsSection
:model-value="formData.productRequirements"
@update:model-value="(value) => (formData.productRequirements = value)"
/>
<TypeEditActionsBar :saving="saving" @reset="resetForm" />
</form>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import TypeEditActionsBar from '~/components/TypeEditActionsBar.vue'
import TypeEditBaseInfoSection from '~/components/TypeEditBaseInfoSection.vue'
import TypeEditCustomFieldsSection from '~/components/TypeEditCustomFieldsSection.vue'
import TypeEditToolbar from '~/components/TypeEditToolbar.vue'
import TypeEditComponentRequirementsSection from '~/components/TypeEditComponentRequirementsSection.vue'
import TypeEditPieceRequirementsSection from '~/components/TypeEditPieceRequirementsSection.vue'
import TypeEditProductRequirementsSection from '~/components/TypeEditProductRequirementsSection.vue'
const props = defineProps({
modelValue: {
type: Object,
required: true
},
saving: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'submit'])
const deepClone = value => JSON.parse(JSON.stringify(value))
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
const normalizeCustomField = (field = {}, index = 0) => {
const clone = deepClone(field)
if (clone.type === 'select') {
if (typeof clone.optionsText !== 'string' || !clone.optionsText.length) {
if (Array.isArray(clone.options)) {
clone.optionsText = clone.options.map(option => String(option).trim()).filter(Boolean).join('\n')
} else {
clone.optionsText = ''
}
}
}
const currentOrder =
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
clone.orderIndex = currentOrder
if (typeof clone?.__key !== 'string' || !clone.__key) {
clone.__key = createFieldKey()
}
return clone
}
const withNormalizedOrder = (items = []) => {
if (!Array.isArray(items)) { return [] }
return items
.map((item, index) => normalizeCustomField(item, index))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((item, index) => ({ ...item, orderIndex: index }))
}
const createDefaultForm = (source = {}) => ({
name: source.name || '',
description: source.description || '',
category: source.category || '',
maintenanceFrequency: source.maintenanceFrequency || '',
customFields: withNormalizedOrder(source.customFields || []),
componentRequirements: withNormalizedOrder(source.componentRequirements || []),
pieceRequirements: withNormalizedOrder(source.pieceRequirements || []),
productRequirements: withNormalizedOrder(source.productRequirements || []),
})
const formData = reactive(createDefaultForm(props.modelValue))
const allExpanded = ref(false)
const expandAllTrigger = ref(0)
let syncingFromParent = false
const toPlainObject = value => JSON.parse(JSON.stringify(value))
const lastSnapshot = ref(toPlainObject(createDefaultForm(props.modelValue)))
watch(
() => props.modelValue,
(value) => {
const normalized = createDefaultForm(value)
if (JSON.stringify(normalized) === JSON.stringify(lastSnapshot.value)) {
return
}
syncingFromParent = true
Object.assign(formData, normalized)
lastSnapshot.value = toPlainObject(normalized)
syncingFromParent = false
},
{ deep: true }
)
watch(
formData,
(value) => {
if (syncingFromParent) { return }
const normalized = createDefaultForm(value)
if (JSON.stringify(normalized) === JSON.stringify(lastSnapshot.value)) {
return
}
lastSnapshot.value = toPlainObject(normalized)
emit('update:modelValue', normalized)
},
{ deep: true }
)
const toggleAllSections = () => {
allExpanded.value = !allExpanded.value
expandAllTrigger.value += 1
}
const resetForm = () => {
const normalized = createDefaultForm(props.modelValue)
syncingFromParent = true
Object.assign(formData, normalized)
lastSnapshot.value = toPlainObject(normalized)
syncingFromParent = false
allExpanded.value = false
expandAllTrigger.value += 1
}
const handleSubmit = () => {
emit('submit')
}
</script>

View File

@@ -1,95 +0,0 @@
<template>
<RequirementListEditor
v-model="requirements"
:type-options="pieceTypes"
type-field="typePieceId"
:labels="labels"
:default-requirement="createDefaultRequirement"
:required-fallback="false"
:min-fallback="0"
:type-loading="loadingPieceTypes"
/>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
type Requirement = Record<string, unknown> & {
id?: string | number
typePieceId?: string | number | null
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper: string
labelPlaceholder: string
minLabel: string
maxLabel: string
maxHelper: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array as () => Requirement[],
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const { pieceTypes, loadPieceTypes, loadingPieceTypes } = usePieceTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value: Requirement[]) => emit('update:modelValue', value),
})
const createDefaultRequirement = (): Requirement => ({
id: undefined,
typePieceId: null,
label: '',
minCount: 0,
maxCount: null,
required: false,
allowNewModels: true,
})
const labels: Labels = {
headerTitle: 'Pièces principales',
addButton: 'Ajouter un groupe',
description:
"Configurez ici les familles de pièces principales attendues pour ce type de machine. Le nombre minimal/maximal est utilisé pour guider la création d'une machine.",
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de pièces.',
typeSelectLabel: 'Type de pièce',
typePlaceholder: 'Sélectionner un type',
labelFieldLabel: 'Libellé',
labelFieldHelper: 'Optionnel',
labelPlaceholder: 'Ex: Vis principale',
minLabel: 'Minimum requis',
maxLabel: 'Maximum autorisé',
maxHelper: 'Laisser vide pour illimité',
requiredLabel: 'Requis',
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
}
onMounted(async () => {
if (!pieceTypes.value.length) {
await loadPieceTypes()
}
})
</script>

View File

@@ -1,95 +0,0 @@
<template>
<RequirementListEditor
v-model="requirements"
:type-options="productTypes"
type-field="typeProductId"
:labels="labels"
:default-requirement="createDefaultRequirement"
:required-fallback="false"
:min-fallback="0"
:type-loading="loadingProductTypes"
/>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { useProductTypes } from '~/composables/useProductTypes'
type Requirement = Record<string, unknown> & {
id?: string | number
typeProductId?: string | number | null
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper: string
labelPlaceholder: string
minLabel: string
maxLabel: string
maxHelper: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array as () => Requirement[],
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const { productTypes, loadProductTypes, loadingProductTypes } = useProductTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value: Requirement[]) => emit('update:modelValue', value),
})
const createDefaultRequirement = (): Requirement => ({
id: undefined,
typeProductId: null,
label: '',
minCount: 0,
maxCount: null,
required: false,
allowNewModels: true,
})
const labels: Labels = {
headerTitle: 'Produits requis',
addButton: 'Ajouter un produit',
description:
"Définissez les produits catalogue attendus pour ce type de machine. Sélectionnez la catégorie de produit, précisez les quantités minimales et maximales, puis indiquez si de nouveaux produits peuvent être créés à l'usage.",
emptyState: 'Aucun produit requis configuré pour le moment.',
typeSelectLabel: 'Catégorie de produit',
typePlaceholder: 'Sélectionner une catégorie',
labelFieldLabel: 'Libellé',
labelFieldHelper: 'Optionnel',
labelPlaceholder: 'Ex : Lubrifiant recommandé',
minLabel: 'Minimum requis',
maxLabel: 'Maximum autorisé',
maxHelper: 'Laisser vide pour illimité',
requiredLabel: 'Requis',
allowNewModelsLabel: "Autoriser la création de nouveaux produits lors de l'instanciation",
}
onMounted(async () => {
if (!productTypes.value.length) {
await loadProductTypes()
}
})
</script>

View File

@@ -1,23 +0,0 @@
<template>
<div class="flex justify-end">
<button type="button" class="btn btn-outline btn-sm" @click="$emit('toggle')">
<IconLucideMinus v-if="allExpanded" class="w-4 h-4 mr-2" aria-hidden="true" />
<IconLucidePlus v-else class="w-4 h-4 mr-2" aria-hidden="true" />
{{ allExpanded ? 'Tout plier' : 'Tout déplier' }}
</button>
</div>
</template>
<script setup>
import IconLucideMinus from '~icons/lucide/minus'
import IconLucidePlus from '~icons/lucide/plus'
defineProps({
allExpanded: {
type: Boolean,
default: false
}
})
defineEmits(['toggle'])
</script>

View File

@@ -1,28 +0,0 @@
<template>
<div class="alert alert-info mb-6">
<div>
<h3 class="font-bold">
Type existant
</h3>
<div class="text-sm">
<p><strong>Catégorie:</strong> {{ type.category || 'Non définie' }}</p>
<p><strong>Maintenance:</strong> {{ type.maintenanceFrequency || 'Non définie' }}</p>
<p><strong>Familles de composants:</strong> {{ type.componentRequirements?.length || 0 }}</p>
<p><strong>Groupes de pièces:</strong> {{ type.pieceRequirements?.length || 0 }}</p>
<p><strong>Produits requis:</strong> {{ type.productRequirements?.length || 0 }}</p>
<p v-if="type.description">
<strong>Description:</strong> {{ type.description }}
</p>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
type: {
type: Object,
required: true
}
})
</script>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
<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">
{{ 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">
<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 { 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
deleteDisabled?: boolean
emptyText?: string
}>(), {
canDelete: false,
deleteDisabled: false,
emptyText: 'Aucun document.',
})
defineEmits<{
(e: 'preview', document: Document): void
(e: 'delete', documentId: string): 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

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

@@ -18,6 +18,15 @@
@keydown.enter.prevent="selectHighlighted" @keydown.enter.prevent="selectHighlighted"
@input="handleInput" @input="handleInput"
> >
<button
v-if="clearable && modelValue"
type="button"
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
aria-label="Effacer la sélection"
@click.stop="clearSelection"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
<button <button
type="button" type="button"
:class="toggleButtonClasses" :class="toggleButtonClasses"
@@ -32,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">
@@ -60,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="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>
@@ -77,6 +86,7 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down' import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -111,6 +121,10 @@ const props = defineProps({
type: [String, Function], type: [String, Function],
default: null default: null
}, },
clearable: {
type: Boolean,
default: false
},
size: { size: {
type: String, type: String,
default: 'md', default: 'md',
@@ -155,7 +169,8 @@ const displayedOptions = computed(() => {
}) })
const inputClasses = computed(() => { const inputClasses = computed(() => {
const base = ['input', 'input-bordered', 'w-full', 'pr-10'] const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
const base = ['input', 'input-bordered', 'w-full', pr]
if (props.size === 'xs') base.push('input-xs') if (props.size === 'xs') base.push('input-xs')
if (props.size === 'sm') base.push('input-sm') if (props.size === 'sm') base.push('input-sm')
if (props.size === 'lg') base.push('input-lg') if (props.size === 'lg') base.push('input-lg')
@@ -269,9 +284,17 @@ function handleInput () {
emit('search', searchTerm.value) emit('search', searchTerm.value)
} }
function clearSelection () {
emit('update:modelValue', '')
searchTerm.value = ''
openDropdown.value = false
}
function closeDropdown () { function closeDropdown () {
openDropdown.value = false openDropdown.value = false
if (selectedOption.value) { if (searchTerm.value.trim() === '' && selectedOption.value) {
emit('update:modelValue', '')
} else if (selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value) searchTerm.value = resolveLabel(selectedOption.value)
} }
} }

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

@@ -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,9 +34,10 @@
<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" 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" />
{{ link.label }} {{ link.label }}
</NuxtLink> </NuxtLink>
</li> </li>
@@ -39,16 +50,19 @@
> >
<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')"
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')" @keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')" @keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
> >
<span>{{ group.label }}</span> <span class="flex items-center gap-2">
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }}
</span>
<IconLucideChevronRight <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"
/> />
@@ -56,15 +70,18 @@
<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 }}
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
{{ unresolvedCount }}
</span>
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
@@ -74,32 +91,31 @@
</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" 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" />
{{ link.label }} {{ link.label }}
</NuxtLink> </NuxtLink>
</li> </li>
@@ -116,32 +132,36 @@
> >
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 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')"
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')" @keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')" @keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
> >
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
{{ group.label }} {{ 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 }}
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
{{ unresolvedCount }}
</span>
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
@@ -152,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>
@@ -166,40 +194,58 @@
<div <div
tabindex="0" tabindex="0"
role="button" role="button"
class="btn btn-ghost btn-circle avatar placeholder" class="indicator cursor-pointer"
> >
<div <span
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center" v-if="unresolvedCount > 0"
class="indicator-item badge badge-warning badge-xs"
> >
<span {{ unresolvedCount }}
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight" </span>
> <div
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
>
<span class="text-xs font-semibold">
{{ activeProfileInitials }} {{ 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="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
</div>
</li> </li>
<li> <div class="divider my-0.5 px-2" />
<NuxtLink to="/profiles/manage" class="justify-between"> <li v-if="isAdmin">
Gestion des profils <NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" /> Administration
<IconLucideChevronRight class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink> </NuxtLink>
</li> </li>
<li>
<NuxtLink to="/comments" class="rounded-lg justify-between text-sm">
Commentaires
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
{{ unresolvedCount }}
</span>
<IconLucideChevronRight v-else class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
</NuxtLink>
</li>
<div class="divider my-0.5 px-2" />
<li> <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>
@@ -211,14 +257,26 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
import { useRoute } from '#imports' import { useRoute } from '#imports'
import { useNavDropdown } from '~/composables/useNavDropdown' import { useNavDropdown } from '~/composables/useNavDropdown'
import { usePermissions } from '~/composables/usePermissions'
import { useProfileSession } from '~/composables/useProfileSession' import { useProfileSession } from '~/composables/useProfileSession'
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 IconLucideFactory from '~icons/lucide/factory'
import IconLucideCpu from '~icons/lucide/cpu'
import IconLucidePuzzle from '~icons/lucide/puzzle'
import IconLucidePackage from '~icons/lucide/package'
import IconLucideLink from '~icons/lucide/link'
import IconLucideSun from '~icons/lucide/sun'
import IconLucideMoon from '~icons/lucide/moon'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png' import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
defineEmits<{ defineEmits<{
@@ -229,25 +287,37 @@ defineEmits<{
interface NavLink { interface NavLink {
to: string to: string
label: string label: string
icon?: Component
} }
interface NavGroup { interface NavGroup {
id: string id: string
label: string label: string
icon?: Component
activePaths: string[] activePaths: string[]
children: NavLink[] children: NavLink[]
} }
const simpleLinks: NavLink[] = [ const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble' }, { to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
{ to: '/machines', label: 'Parc Machines' }, { to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
{ to: '/machine-skeleton', label: 'Squelettes de machine' },
] ]
const navGroups: NavGroup[] = [ const navGroups: NavGroup[] = [
{
id: 'component',
label: 'Composants',
icon: IconLucideCpu,
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{ {
id: 'pieces', id: 'pieces',
label: 'Pièces', label: 'Pièces',
icon: IconLucidePuzzle,
activePaths: ['/piece-category', '/pieces-catalog'], activePaths: ['/piece-category', '/pieces-catalog'],
children: [ children: [
{ to: '/pieces-catalog', label: 'Catalogue des pièces' }, { to: '/pieces-catalog', label: 'Catalogue des pièces' },
@@ -257,29 +327,24 @@ const navGroups: NavGroup[] = [
{ {
id: 'products', id: 'products',
label: 'Produits', label: 'Produits',
icon: IconLucidePackage,
activePaths: ['/product-category', '/product-catalog'], activePaths: ['/product-category', '/product-catalog'],
children: [ children: [
{ to: '/product-catalog', label: 'Catalogue des produits' }, { to: '/product-catalog', label: 'Catalogue des produits' },
{ to: '/product-category', label: 'Catégorie de produit' }, { to: '/product-category', label: 'Catégorie de produit' },
], ],
}, },
{
id: 'component',
label: 'Composant',
activePaths: ['/component-category', '/component-catalog'],
children: [
{ to: '/component-catalog', label: 'Catalogue des composants' },
{ to: '/component-category', label: 'Catégorie de composant' },
],
},
{ {
id: 'resources', id: 'resources',
label: 'Ressources liées', label: 'Ressources liées',
activePaths: ['/sites', '/documents', '/constructeurs'], icon: IconLucideLink,
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
children: [ children: [
{ to: '/sites', label: 'Sites' }, { to: '/sites', label: 'Sites' },
{ to: '/documents', label: 'Documents' }, { to: '/documents', label: 'Documents' },
{ to: '/constructeurs', label: 'Fournisseurs' }, { to: '/constructeurs', label: 'Fournisseurs' },
{ to: '/comments', label: 'Commentaires' },
{ to: '/activity-log', label: 'Journal d\'activité' },
], ],
}, },
] ]
@@ -287,6 +352,27 @@ const navGroups: NavGroup[] = [
const route = useRoute() const route = useRoute()
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown() const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
const { activeProfile } = useProfileSession() const { activeProfile } = useProfileSession()
const { isAdmin, canEdit } = usePermissions()
const { fetchUnresolvedCount } = useComments()
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
const unresolvedCount = ref(0)
let pollInterval: ReturnType<typeof setInterval> | null = null
const refreshUnresolvedCount = async () => {
if (!activeProfile.value) return
unresolvedCount.value = await fetchUnresolvedCount()
}
onMounted(() => {
initDarkMode()
refreshUnresolvedCount()
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
})
onBeforeUnmount(() => {
if (pollInterval) clearInterval(pollInterval)
})
const isActive = (path: string) => { const isActive = (path: string) => {
if (path === '/') { if (path === '/') {
@@ -302,21 +388,33 @@ 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(() => {
if (isAdmin.value) return 'Admin'
if (canEdit.value) return 'Gestionnaire'
return 'Lecteur'
})
const roleBadgeClass = computed(() => {
if (isAdmin.value) return 'badge-error'
if (canEdit.value) return 'badge-warning'
return 'badge-info'
})
const activeProfileLabel = computed(() => { const activeProfileLabel = computed(() => {
if (!activeProfile.value) { if (!activeProfile.value) {
return 'Profil inconnu' return 'Profil inconnu'
@@ -343,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

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

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"> <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>
@@ -20,15 +20,32 @@
</button> </button>
</div> </div>
<ComponentHierarchy <div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
:components="components" Aucun composant associé à cette machine.
:is-edit-mode="isEditMode" </div>
:collapse-all="collapsed"
:toggle-token="collapseToggleToken" <div v-else class="space-y-2">
@update="$emit('update-component', $event)" <div v-for="component in components" :key="component.id">
@edit-piece="$emit('edit-piece', $event)" <ComponentHierarchy
@custom-field-update="$emit('custom-field-update', $event)" :components="[component]"
/> :is-edit-mode="false"
:show-delete="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@edit-piece="$emit('edit-piece', $event)"
@delete="$emit('remove-component', component.linkId || component.id)"
/>
</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>
@@ -49,5 +66,7 @@ defineEmits<{
'update-component': [component: any] 'update-component': [component: any]
'edit-piece': [piece: any] 'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any] 'custom-field-update': [fieldUpdate: any]
'add-component': []
'remove-component': [linkId: string]
}>() }>()
</script> </script>

View File

@@ -0,0 +1,124 @@
<template>
<section class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
Définitions des champs personnalisés
</h3>
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="saving"
@click="$emit('save')"
>
<span v-if="saving" class="loading loading-spinner loading-xs" />
Enregistrer les champs
</button>
</div>
<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<{
save: []
'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

@@ -4,25 +4,6 @@
<h1 class="text-3xl font-bold"> <h1 class="text-3xl font-bold">
{{ title }} {{ title }}
</h1> </h1>
<div class="btn-group w-full max-w-xs print:hidden" data-print-hide>
<button
type="button"
class="btn btn-sm"
:class="isDetailsView ? 'btn-primary' : 'btn-outline'"
@click="$emit('change-view', 'details')"
>
Vue machine
</button>
<button
type="button"
class="btn btn-sm"
:class="isSkeletonView ? 'btn-primary' : 'btn-outline'"
:disabled="!hasSkeletonRequirements"
@click="$emit('change-view', 'skeleton')"
>
Squelette
</button>
</div>
</div> </div>
<div class="flex items-center gap-2 print:hidden" data-print-hide> <div class="flex items-center gap-2 print:hidden" data-print-hide>
<button <button
@@ -43,7 +24,7 @@
{{ isEditMode ? 'Voir détails' : 'Modifier' }} {{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button> </button>
<button <button
v-if="isDetailsView && !isEditMode" v-if="!isEditMode"
@click="$emit('open-print')" @click="$emit('open-print')"
type="button" type="button"
class="btn btn-outline btn-secondary" class="btn btn-outline btn-secondary"
@@ -62,14 +43,10 @@ import IconLucidePrinter from '~icons/lucide/printer'
defineProps<{ defineProps<{
title: string title: string
isDetailsView: boolean
isSkeletonView: boolean
isEditMode: boolean isEditMode: boolean
hasSkeletonRequirements: boolean
}>() }>()
defineEmits<{ defineEmits<{
'change-view': [view: 'details' | 'skeleton']
'toggle-edit': [] 'toggle-edit': []
'open-print': [] 'open-print': []
}>() }>()

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>
@@ -32,8 +32,8 @@
:class="documentThumbnailClass(doc)" :class="documentThumbnailClass(doc)"
> >
<img <img
v-if="isImageDocument(doc) && doc.path" v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
:src="doc.path" :src="doc.fileUrl || doc.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${doc.name}`" :alt="`Aperçu de ${doc.name}`"
> >
@@ -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">
@@ -20,6 +20,29 @@
{{ 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); $emit('blur-field')"
>
<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>
@@ -49,30 +72,30 @@
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"> <div v-else class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
<div v-if="machineConstructeursDisplay.length" class="space-y-1"> <div v-if="machineConstructeursDisplay.length" class="flex flex-wrap gap-2">
<div <span
v-for="constructeur in machineConstructeursDisplay" v-for="constructeur in machineConstructeursDisplay"
:key="constructeur.id" :key="constructeur.id"
class="flex flex-col" class="badge badge-ghost gap-1"
> >
<span class="font-medium">{{ constructeur.name }}</span> {{ constructeur.name }}
<span <span
v-if="formatConstructeurContactSummary(constructeur)" v-if="formatConstructeurContactSummary(constructeur)"
class="text-xs text-gray-500" class="text-xs opacity-60"
> >
{{ formatConstructeurContactSummary(constructeur) }} · {{ formatConstructeurContactSummary(constructeur) }}
</span> </span>
</div> </span>
</div> </div>
<span v-else class="font-medium">Non défini</span> <span v-else class="text-base-content/50">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"
@@ -120,17 +143,16 @@
{{ option }} {{ option }}
</option> </option>
</select> </select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2"> <label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
<input <input
:value="field.value ?? ''"
type="checkbox" type="checkbox"
class="checkbox checkbox-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)" @blur="$emit('update-custom-field', field)"
/> >
<span class="text-sm">{{ 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>
</div> </label>
<input <input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
:value="field.value ?? ''" :value="field.value ?? ''"
@@ -152,34 +174,69 @@
</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"
@save="fieldDefs.saveDefinitions()"
@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 MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
import { import {
formatConstructeurContact as formatConstructeurContactSummary, formatConstructeurContact as formatConstructeurContactSummary,
} from '~/shared/constructeurUtils' } from '~/shared/constructeurUtils'
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils' import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
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
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': [] 'blur-field': []
'set-custom-field-value': [field: any, value: unknown] 'set-custom-field-value': [field: any, value: unknown]
'update-custom-field': [field: any] '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 })
</script> </script>

View File

@@ -1,34 +1,71 @@
<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>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
>
<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 class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
<PieceItem Aucune pièce associée à cette machine.
v-for="piece in pieces"
:key="piece.id"
:piece="piece"
:is-edit-mode="isEditMode"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
/>
</div> </div>
<div v-else class="space-y-2">
<div v-for="piece in pieces" :key="piece.id">
<PieceItem
:piece="piece"
:is-edit-mode="isEditMode"
:show-delete="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@update="$emit('update-piece', $event)"
@edit="$emit('edit-piece', $event)"
@delete="$emit('remove-piece', piece.linkId || piece.id)"
/>
</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>
<script setup lang="ts"> <script setup lang="ts">
import IconLucideChevronRight from '~icons/lucide/chevron-right'
defineProps<{ defineProps<{
pieces: any[] pieces: any[]
isEditMode: boolean isEditMode: boolean
collapsed: boolean
collapseToggleToken: number
}>() }>()
defineEmits<{ defineEmits<{
'toggle-collapse': []
'update-piece': [piece: any] 'update-piece': [piece: any]
'edit-piece': [piece: any] 'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any] 'add-piece': []
'remove-piece': [linkId: string]
}>() }>()
</script> </script>

View File

@@ -1,11 +1,18 @@
<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
:document="previewDocument"
:visible="previewVisible"
:documents="previewDocumentList"
@close="closePreview"
/>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="card-title">Produits associés</h2> <h2 class="card-title">Produits associés</h2>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
Produits sélectionnés directement pour cette machine selon le squelette. Produits sélectionnés directement pour cette machine.
</p> </p>
</div> </div>
<span class="badge badge-outline" v-if="products.length"> <span class="badge badge-outline" v-if="products.length">
@@ -17,15 +24,26 @@
<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-1" class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2"
> >
<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 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>
@@ -39,24 +57,119 @@
<span class="font-medium">Prix indicatif :</span> <span class="font-medium">Prix indicatif :</span>
<span class="ml-1">{{ product.priceLabel }}</span> <span class="ml-1">{{ product.priceLabel }}</span>
</p> </p>
<!-- Documents liés au produit -->
<div v-if="product.documents?.length" class="mt-2 space-y-1">
<p class="text-xs font-medium text-base-content/70">Documents :</p>
<div
v-for="doc in product.documents"
:key="doc.id || doc.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 text-xs">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-6"
>
<component
:is="documentIcon(doc).component"
class="h-4 w-4"
:class="documentIcon(doc).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium text-base-content">{{ doc.name }}</div>
<div class="text-xs text-base-content/60">
{{ doc.mimeType || 'Inconnu' }} {{ formatSize(doc.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(doc)"
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
@click="openPreview(doc, product.documents || [])"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(doc)"
>
Télécharger
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
<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>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { canPreviewDocument } from '~/utils/documentPreview'
import {
formatSize,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
defineProps<{ defineProps<{
products: Array<{ products: Array<{
id?: string | null id?: string | null
linkId?: string | null
name?: string name?: string
reference?: string | null reference?: string | null
supplierLabel?: string | null supplierLabel?: string | null
priceLabel?: string | null priceLabel?: string | null
groupLabel?: string groupLabel?: string
documents?: Array<{
id?: string
name?: string
mimeType?: string
size?: number
fileUrl?: string
downloadUrl?: string
}>
}> }>
isEditMode: boolean
}>() }>()
defineEmits<{
'add-product': []
'remove-product': [linkId: string]
}>()
const previewDocument = ref<any>(null)
const previewVisible = ref(false)
const previewDocumentList = ref<any[]>([])
const openPreview = (doc: any, docs: any[]) => {
previewDocument.value = doc
previewDocumentList.value = docs
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
</script> </script>

View File

@@ -1,193 +0,0 @@
<template>
<div
v-if="componentRequirementGroups.length || pieceRequirementGroups.length || productRequirementGroups.length"
class="card bg-base-100 shadow-lg"
>
<div class="card-body space-y-6">
<div>
<h2 class="card-title">Structure sélectionnée</h2>
<p class="text-sm text-gray-500">
Synthèse des familles définies dans le type et des modèles utilisés pour cette machine.
</p>
</div>
<!-- Component requirement groups -->
<div v-if="componentRequirementGroups.length" class="space-y-4">
<h3 class="text-sm font-semibold text-gray-700">Composants</h3>
<div
v-for="group in componentRequirementGroups"
:key="group.requirement.id"
class="rounded-lg border border-base-200 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<h4 class="font-medium text-sm">
{{ group.requirement.label || group.requirement.typeComposant?.name || 'Famille de composants' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ group.requirement.typeComposant?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
</p>
</div>
<span class="badge badge-outline badge-sm">{{ group.components.length }} composant(s)</span>
</div>
<div v-if="group.components.length" class="space-y-2">
<div
v-for="component in group.components"
:key="component.id"
class="flex flex-wrap items-center gap-2 text-sm"
>
<span class="font-medium">{{ component.name }}</span>
<span v-if="component.parentComposantId" class="text-xs text-gray-500">
(Sous-composant)
</span>
<div
v-if="summarizeCustomFields(component.customFields || []).length"
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
>
<span
v-for="field in summarizeCustomFields(component.customFields || [])"
:key="field.key"
class="badge badge-ghost badge-sm whitespace-pre-wrap"
>
<span class="font-medium">{{ field.label }} :</span>
<span class="ml-1">{{ field.value }}</span>
</span>
</div>
<SkeletonProductDisplay :product-display="component.__productDisplay" />
</div>
</div>
<p v-else class="text-xs text-gray-500">Aucun composant rattaché à ce groupe.</p>
</div>
</div>
<!-- Piece requirement groups -->
<div v-if="pieceRequirementGroups.length" class="space-y-4">
<h3 class="text-sm font-semibold text-gray-700">Pièces principales</h3>
<div
v-for="group in pieceRequirementGroups"
:key="group.requirement.id"
class="rounded-lg border border-base-200 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<h4 class="font-medium text-sm">
{{ group.requirement.label || group.requirement.typePiece?.name || 'Groupe de pièces' }}
</h4>
<p class="text-xs text-gray-500">
Type : {{ group.requirement.typePiece?.name || 'Non défini' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
</p>
</div>
<span class="badge badge-outline badge-sm">{{ group.pieces.length }} pièce(s)</span>
</div>
<div v-if="group.pieces.length" class="space-y-2">
<div
v-for="piece in group.pieces"
:key="piece.id"
class="flex flex-wrap items-center gap-2 text-sm"
>
<span class="font-medium">{{ piece.name }}</span>
<span v-if="piece.parentComponentName" class="text-xs text-gray-500">
(Rattachée à {{ piece.parentComponentName }})
</span>
<div
v-if="summarizeCustomFields(piece.customFields || []).length"
class="w-full flex flex-wrap gap-2 text-xs text-gray-600"
>
<span
v-for="field in summarizeCustomFields(piece.customFields || [])"
:key="field.key"
class="badge badge-ghost badge-sm whitespace-pre-wrap"
>
<span class="font-medium">{{ field.label }} :</span>
<span class="ml-1">{{ field.value }}</span>
</span>
</div>
<SkeletonProductDisplay :product-display="piece.__productDisplay" />
</div>
</div>
<p v-else class="text-xs text-gray-500">Aucune pièce rattachée à ce groupe.</p>
</div>
</div>
<!-- Product requirement groups -->
<div v-if="productRequirementGroups.length" class="space-y-4">
<h3 class="text-sm font-semibold text-gray-700">Produits requis</h3>
<div
v-for="group in productRequirementGroups"
:key="group.requirement.id"
class="rounded-lg border border-base-200 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<h4 class="font-medium text-sm">
{{ group.requirement.label || group.requirement.typeProduct?.name || 'Groupe de produits' }}
</h4>
<p class="text-xs text-gray-500">
Catégorie : {{ group.requirement.typeProduct?.name || 'Non définie' }} · Min {{ group.requirement.minCount ?? (group.requirement.required ? 1 : 0) }} · Max {{ group.requirement.maxCount ?? '∞' }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-outline badge-sm">Total {{ group.totalCount }}</span>
<span class="badge badge-ghost badge-sm">Direct {{ group.directProducts.length }}</span>
</div>
</div>
<div class="text-xs text-gray-500 mb-3">
Via composants : {{ group.componentCount }} &bull; Via pièces : {{ group.pieceCount }}
</div>
<div v-if="group.directProducts.length" class="space-y-2">
<div
v-for="product in group.directProducts"
:key="product.id || product.name"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm"
>
<div class="font-medium">{{ product.name }}</div>
<div v-if="product.reference" class="text-xs text-gray-500">
Référence : {{ product.reference }}
</div>
<div v-if="product.supplierLabel" class="text-xs text-gray-500">
Fournisseurs : {{ product.supplierLabel }}
</div>
<div v-if="product.priceLabel" class="text-xs text-gray-500">
Prix indicatif : {{ product.priceLabel }}
</div>
</div>
</div>
<p v-else class="text-xs text-gray-500">
Aucune sélection directe. Couverture assurée via composants ou pièces associés.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineComponent } from 'vue'
import { summarizeCustomFields } from '~/shared/utils/customFieldUtils'
defineProps<{
componentRequirementGroups: any[]
pieceRequirementGroups: any[]
productRequirementGroups: any[]
}>()
const SkeletonProductDisplay = defineComponent({
name: 'SkeletonProductDisplay',
props: {
productDisplay: { type: Object, default: null },
},
template: `
<div v-if="productDisplay" class="w-full text-xs text-gray-600 space-y-1">
<div><span class="font-medium">Produit :</span> <span>{{ productDisplay.name }}</span></div>
<div v-if="productDisplay.category"><span class="font-medium">Catégorie :</span> <span>{{ productDisplay.category }}</span></div>
<div v-if="productDisplay.reference"><span class="font-medium">Référence :</span> <span>{{ productDisplay.reference }}</span></div>
<div v-if="productDisplay.suppliers"><span class="font-medium">Fournisseurs :</span> <span>{{ productDisplay.suppliers }}</span></div>
<div v-if="productDisplay.price"><span class="font-medium">Prix indicatif :</span> <span>{{ productDisplay.price }}</span></div>
</div>
`,
})
</script>

View File

@@ -1,205 +0,0 @@
<template>
<div v-if="preview" class="space-y-4">
<div class="border border-base-200 rounded-lg bg-base-100/80">
<div class="p-4 space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm font-semibold text-gray-700">
<IconLucideEye class="w-4 h-4" aria-hidden="true" />
<span>Prévisualisation avant création</span>
</div>
<span class="badge" :class="getStatusBadgeClass(preview.status)">
{{ preview.status === 'ready' ? 'Prête à créer' : preview.status === 'warning' ? 'À compléter' : 'Bloquante' }}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div
v-for="field in preview.base.fields"
:key="field.key"
class="flex flex-col gap-1"
>
<span class="text-[11px] uppercase tracking-wide text-gray-500">{{ field.label }}</span>
<span
class="text-sm font-medium"
:class="field.status === 'missing'
? 'text-error'
: field.status === 'optional'
? 'text-gray-500 italic'
: 'text-gray-900'"
>
{{ field.display }}
</span>
</div>
</div>
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
<span class="badge badge-ghost badge-sm">Type : {{ preview.type.name }}</span>
<span v-if="preview.type.category" class="badge badge-ghost badge-sm">Catégorie : {{ preview.type.category }}</span>
<span class="badge badge-ghost badge-sm">Structure JSON : {{ preview.type.hasStructuredDefinition ? 'Oui' : 'Legacy' }}</span>
</div>
<!-- Base issues -->
<div v-if="preview.base.issues.length" class="rounded-md bg-warning/10 border border-warning/30 p-3 text-xs text-warning">
<p class="font-medium mb-1">
Informations générales incomplètes :
</p>
<ul class="space-y-1">
<li v-for="issue in preview.base.issues" :key="issue.message">
<button
type="button"
class="flex w-full items-start gap-2 text-left hover:underline"
@click="handleIssueClick(issue)"
>
<span class="mt-0.5 text-[8px] leading-none">&bull;</span>
<span>{{ issue.message }}</span>
</button>
</li>
</ul>
</div>
<!-- Component groups -->
<div v-if="preview.componentGroups.length" class="space-y-3">
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Composants hérités
</h5>
<PreviewRequirementGroup
v-for="group in preview.componentGroups"
:key="group.id"
:group="group"
/>
</div>
<div v-else class="text-xs text-gray-500">
Aucun composant n'est requis pour ce type de machine.
</div>
<!-- Piece groups -->
<div v-if="preview.pieceGroups.length" class="space-y-3">
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Pièces associées
</h5>
<PreviewRequirementGroup
v-for="group in preview.pieceGroups"
:key="group.id"
:group="group"
/>
</div>
<div v-else class="text-xs text-gray-500">
Aucun groupe de pièces à configurer pour ce type.
</div>
<!-- Product groups -->
<div v-if="preview.productGroups.length" class="space-y-3">
<h5 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Produits requis
</h5>
<div
v-for="group in preview.productGroups"
:key="group.id"
:id="`product-group-${group.id}`"
class="border border-base-200 rounded-md p-3 space-y-3"
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<p class="text-sm font-semibold">
{{ group.label }}
</p>
<p class="text-xs text-gray-500">
Catégorie : {{ group.typeName }} · Min {{ group.min }} ·
{{ group.max !== null ? `Max ${group.max}` : 'Max ' }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
Couverture : {{ group.count }}
</span>
<span class="badge badge-ghost badge-sm">
Direct {{ group.completed }} / {{ group.total || 0 }}
</span>
</div>
</div>
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
<ul class="list-disc pl-4 space-y-1">
<li v-for="issue in group.issues" :key="issue.message">
{{ issue.message }}
</li>
</ul>
</div>
<ul v-if="group.entries?.length" class="space-y-2">
<li
v-for="entry in group.entries"
:key="entry.key"
class="flex items-start gap-3"
>
<component
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
class="w-4 h-4 mt-0.5"
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
aria-hidden="true"
/>
<div class="flex-1">
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
{{ entry.title }}
</p>
<p v-if="entry.subtitle" class="text-xs text-gray-500">
{{ entry.subtitle }}
</p>
</div>
</li>
</ul>
<p v-else class="text-xs text-gray-500">
Couverture assurée via composants ou pièces liés.
</p>
</div>
</div>
<!-- Global issues -->
<div
v-if="preview.issues.length && preview.status !== 'ready'"
class="rounded-md border border-warning/30 bg-warning/10 p-3 text-xs text-warning"
>
<div class="flex items-start gap-2">
<IconLucideAlertTriangle class="w-4 h-4 mt-0.5" aria-hidden="true" />
<div class="space-y-1">
<p class="font-medium">
Points à vérifier avant la création :
</p>
<ul class="space-y-1">
<li v-for="issue in preview.issues" :key="`${issue.scope}-${issue.message}`">
<button
type="button"
class="flex w-full items-start gap-2 text-left hover:underline"
@click="handleIssueClick(issue)"
>
<span class="mt-0.5 text-[8px] leading-none">&bull;</span>
<span>
<span class="font-medium">{{ issue.scope }} :</span> {{ issue.message }}
</span>
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
getStatusBadgeClass,
handleIssueClick,
} from '~/composables/useMachineCreatePreview'
import PreviewRequirementGroup from './PreviewRequirementGroup.vue'
import IconLucideEye from '~icons/lucide/eye'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
import IconLucideCircle from '~icons/lucide/circle'
defineProps<{
preview: any
}>()
</script>

View File

@@ -1,59 +0,0 @@
<template>
<div class="border border-base-200 rounded-md p-3 space-y-3">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<p class="text-sm font-semibold">
{{ group.label }}
</p>
<p class="text-xs text-gray-500">
Type : {{ group.typeName }} · Min {{ group.min }} ·
{{ group.max !== null ? `Max ${group.max}` : 'Max ∞' }}
</p>
</div>
<span class="badge badge-sm" :class="getStatusBadgeClass(group.status)">
{{ group.completed }} / {{ group.total || 0 }} complétée(s)
</span>
</div>
<div v-if="group.issues.length" class="rounded bg-warning/10 border border-warning/30 p-2 text-[11px] text-warning">
<ul class="list-disc pl-4 space-y-1">
<li v-for="issue in group.issues" :key="issue.message">
{{ issue.message }}
</li>
</ul>
</div>
<ul class="space-y-2">
<li
v-for="entry in group.entries"
:key="entry.key"
class="flex items-start gap-3"
>
<component
:is="entry.status === 'complete' ? IconLucideCheckCircle2 : IconLucideCircle"
class="w-4 h-4 mt-0.5"
:class="entry.status === 'complete' ? 'text-success' : 'text-gray-400'"
aria-hidden="true"
/>
<div class="flex-1">
<p class="text-sm font-medium" :class="entry.status === 'complete' ? 'text-gray-900' : 'text-gray-500'">
{{ entry.title }}
</p>
<p v-if="entry.subtitle" class="text-xs text-gray-500">
{{ entry.subtitle }}
</p>
</div>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { getStatusBadgeClass } from '~/composables/useMachineCreatePreview'
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
import IconLucideCircle from '~icons/lucide/circle'
defineProps<{
group: any
}>()
</script>

View File

@@ -1,126 +0,0 @@
<template>
<div v-if="requirements?.length" class="space-y-4">
<h4 class="text-sm font-semibold">
Sélection des composants
</h4>
<div
v-for="requirement in requirements"
:id="`component-group-${requirement.id}`"
:key="requirement.id"
class="border border-base-200 rounded-lg p-4 space-y-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h5 class="font-medium text-sm">
{{ requirement.label || requirement.typeComposant?.name || 'Famille de composants' }}
</h5>
<p class="text-xs text-gray-500">
Type : {{ requirement.typeComposant?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
· Max : {{ requirement.maxCount ?? '∞' }}
</p>
</div>
<button
type="button"
class="btn btn-sm btn-outline"
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
@click="$emit('add-entry', requirement)"
>
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
Aucun composant sélectionné pour ce groupe.
</div>
<div
v-for="(entry, entryIndex) in getEntries(requirement.id)"
:key="`${requirement.id}-${entryIndex}`"
class="bg-base-200/60 rounded-md p-3 space-y-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
<span>
Type appliqué :
{{ resolveTypeLabel(requirement, entry) }}
</span>
<button
type="button"
class="btn btn-square btn-xs btn-error"
@click="$emit('remove-entry', requirement.id, entryIndex)"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="grid grid-cols-1 gap-3">
<div class="space-y-2">
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Composant existant</span>
</label>
<SearchSelect
:model-value="entry.composantId || ''"
:options="getOptions(requirement, entry)"
:loading="loading"
size="sm"
placeholder="Rechercher un composant…"
empty-text="Aucun composant disponible"
:option-label="optionLabel"
:option-description="optionDescription"
@update:modelValue="$emit('set-component', requirement, entryIndex, $event || '')"
/>
</div>
<p
v-if="getOptions(requirement, entry).length === 0"
class="text-xs text-error"
>
Aucun composant disponible pour cette famille.
</p>
</div>
<div
v-if="entry.composantId"
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
>
<div class="font-medium">
{{ findById(entry.composantId)?.name || "Composant" }}
</div>
<div>
Référence : {{ findById(entry.composantId)?.reference || "—" }}
</div>
<div>
Fournisseur :
{{ findById(entry.composantId)?.constructeur?.name || findById(entry.composantId)?.constructeurName || "—" }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SearchSelect from '~/components/common/SearchSelect.vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
defineProps<{
requirements: any[]
loading: boolean
getEntries: (requirementId: string) => any[]
getOptions: (requirement: any, entry: any) => any[]
resolveTypeLabel: (requirement: any, entry: any) => string
findById: (id: string) => any
optionLabel: (item: any) => string
optionDescription: (item: any) => string
}>()
defineEmits<{
'add-entry': [requirement: any]
'remove-entry': [requirementId: string, entryIndex: number]
'set-component': [requirement: any, entryIndex: number, componentId: string]
}>()
</script>

View File

@@ -1,130 +0,0 @@
<template>
<div v-if="requirements?.length" class="space-y-4">
<h4 class="text-sm font-semibold">
Sélection des pièces principales
</h4>
<div
v-for="requirement in requirements"
:id="`piece-group-${requirement.id}`"
:key="requirement.id"
class="border border-base-200 rounded-lg p-4 space-y-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h5 class="font-medium text-sm">
{{ requirement.label || requirement.typePiece?.name || 'Groupe de pièces' }}
</h5>
<p class="text-xs text-gray-500">
Type : {{ requirement.typePiece?.name || 'Non défini' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
· Max : {{ requirement.maxCount ?? '∞' }}
</p>
</div>
<button
type="button"
class="btn btn-sm btn-outline"
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
@click="$emit('add-entry', requirement)"
>
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
Aucune pièce sélectionnée pour ce groupe.
</div>
<div
v-for="(entry, entryIndex) in getEntries(requirement.id)"
:key="`${requirement.id}-piece-${entryIndex}`"
class="bg-base-200/60 rounded-md p-3 space-y-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
<span>
Type appliqué :
{{ resolveTypeLabel(requirement, entry) }}
</span>
<button
type="button"
class="btn btn-square btn-xs btn-error"
@click="$emit('remove-entry', requirement.id, entryIndex)"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="grid grid-cols-1 gap-3">
<div class="space-y-2">
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Pièce existante</span>
</label>
<SearchSelect
:model-value="entry.pieceId || ''"
:options="getOptions(requirement, entry, entryIndex)"
:loading="loading || pieceLoadingByKey[getPieceKey(requirement, entryIndex)]"
size="sm"
placeholder="Rechercher une pièce…"
empty-text="Aucune pièce disponible"
:option-label="optionLabel"
:option-description="optionDescription"
@search="(term: string) => $emit('search', requirement, entryIndex, term)"
@update:modelValue="$emit('set-piece', requirement, entryIndex, $event || '')"
/>
</div>
<p
v-if="getOptions(requirement, entry, entryIndex).length === 0"
class="text-xs text-error"
>
Aucune pièce disponible pour cette famille.
</p>
</div>
<div
v-if="entry.pieceId"
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
>
<div class="font-medium">
{{ findById(entry.pieceId)?.name || "Pièce" }}
</div>
<div>
Référence : {{ findById(entry.pieceId)?.reference || "—" }}
</div>
<div>
Fournisseur :
{{ findById(entry.pieceId)?.constructeur?.name || findById(entry.pieceId)?.constructeurName || "—" }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SearchSelect from '~/components/common/SearchSelect.vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
defineProps<{
requirements: any[]
loading: boolean
pieceLoadingByKey: Record<string, boolean>
getEntries: (requirementId: string) => any[]
getOptions: (requirement: any, entry: any, entryIndex: number) => any[]
getPieceKey: (requirement: any, entryIndex: number) => string
resolveTypeLabel: (requirement: any, entry: any) => string
findById: (id: string) => any
optionLabel: (item: any) => string
optionDescription: (item: any) => string
}>()
defineEmits<{
'add-entry': [requirement: any]
'remove-entry': [requirementId: string, entryIndex: number]
'set-piece': [requirement: any, entryIndex: number, pieceId: string]
'search': [requirement: any, entryIndex: number, term: string]
}>()
</script>

View File

@@ -1,142 +0,0 @@
<template>
<div v-if="requirements?.length" class="space-y-4">
<h4 class="text-sm font-semibold">
Produits catalogue requis
</h4>
<div
v-for="requirement in requirements"
:id="`product-group-${requirement.id}`"
:key="requirement.id"
class="border border-base-200 rounded-lg p-4 space-y-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<h5 class="font-medium text-sm">
{{ requirement.label || requirement.typeProduct?.name || 'Groupe de produits' }}
</h5>
<p class="text-xs text-gray-500">
Catégorie : {{ requirement.typeProduct?.name || 'Non définie' }} · Min : {{ requirement.minCount ?? (requirement.required ? 1 : 0) }}
· Max : {{ requirement.maxCount ?? '∞' }}
</p>
<p
v-if="(requirement.allowNewModels ?? true) === false"
class="text-xs text-error"
>
Sélection de produits existants uniquement.
</p>
</div>
<button
type="button"
class="btn btn-sm btn-outline"
:disabled="requirement.maxCount !== null && getEntries(requirement.id).length >= requirement.maxCount"
@click="$emit('add-entry', requirement)"
>
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter
</button>
</div>
<div v-if="getEntries(requirement.id).length === 0" class="text-xs text-gray-500">
Aucun produit sélectionné pour ce groupe.
</div>
<div
v-for="(entry, entryIndex) in getEntries(requirement.id)"
:key="`${requirement.id}-product-${entryIndex}`"
class="bg-base-200/60 rounded-md p-3 space-y-4"
>
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500">
<span>
Catégorie appliquée :
{{ requirement.typeProduct?.name || 'Non définie' }}
</span>
<button
type="button"
class="btn btn-square btn-xs btn-error"
@click="$emit('remove-entry', requirement.id, entryIndex)"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="grid grid-cols-1 gap-3">
<div class="space-y-2">
<div class="form-control">
<label class="label">
<span class="label-text text-xs">Produit existant</span>
</label>
<ProductSelect
:model-value="entry.productId || ''"
:type-product-id="requirement.typeProductId || requirement.typeProduct?.id || null"
:placeholder="productsLoading ? 'Chargement…' : 'Sélectionner un produit…'"
empty-text="Aucun produit disponible pour cette catégorie"
:disabled="productsLoading"
@update:modelValue="$emit('set-product', requirement, entryIndex, $event || '')"
/>
</div>
<p
v-if="!productsLoading && getProductOptions(requirement).length === 0"
class="text-xs text-error"
>
Aucun produit existant pour cette catégorie. Créez-en un depuis le catalogue.
</p>
</div>
<div
v-if="entry.productId"
class="bg-base-300/60 rounded-md p-3 text-xs text-gray-600 space-y-1"
>
<div class="font-medium">
{{ findById(entry.productId)?.name || 'Produit' }}
</div>
<div>
Référence : {{ findById(entry.productId)?.reference || "—" }}
</div>
<div>
Prix indicatif :
<span
v-if="findById(entry.productId)?.supplierPrice !== undefined && findById(entry.productId)?.supplierPrice !== null"
>
{{ Number(findById(entry.productId)?.supplierPrice).toFixed(2) }}
</span>
<span v-else>
</span>
</div>
<div>
Fournisseurs :
<span v-if="findById(entry.productId)?.constructeurs?.length">
{{ findById(entry.productId)?.constructeurs.map((constructeur: any) => constructeur?.name).filter(Boolean).join(', ') }}
</span>
<span v-else>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ProductSelect from '~/components/ProductSelect.vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
defineProps<{
requirements: any[]
productsLoading: boolean
getEntries: (requirementId: string) => any[]
getProductOptions: (requirement: any) => any[]
findById: (id: string) => any
}>()
defineEmits<{
'add-entry': [requirement: any]
'remove-entry': [requirementId: string, entryIndex: number]
'set-product': [requirement: any, entryIndex: number, productId: string]
}>()
</script>

View File

@@ -0,0 +1,172 @@
<template>
<dialog class="modal" :class="{ 'modal-open': open }">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold text-base-content">
Convertir la catégorie
</h3>
<!-- Loading state -->
<div v-if="checking" class="mt-4 flex items-center gap-2 text-sm text-info">
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Vérification de la conversion
</div>
<!-- Error state -->
<div v-else-if="checkError" class="mt-4 text-sm text-error">
{{ checkError }}
</div>
<!-- Blocked state -->
<template v-else-if="checkResult && !checkResult.canConvert">
<p class="mt-3 text-sm text-base-content/70">
La conversion de « {{ modelType?.name }} » est impossible pour les raisons suivantes :
</p>
<ul class="mt-3 space-y-1">
<li
v-for="(blocker, i) in checkResult.blockers"
:key="i"
class="flex items-start gap-2 rounded-lg border border-error/20 bg-error/5 px-3 py-2 text-sm text-error"
>
<IconLucideCircleX class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
{{ blocker }}
</li>
</ul>
</template>
<!-- Eligible state -->
<template v-else-if="checkResult && checkResult.canConvert">
<div class="mt-3 rounded-lg border border-warning/20 bg-warning/5 px-4 py-3">
<p class="text-sm font-medium text-warning">
{{ directionLabel }}
</p>
<p class="mt-1 text-sm text-base-content/70">
{{ checkResult.itemCount }} élément(s) seront convertis. Cette opération est irréversible.
</p>
</div>
<div
v-if="checkResult.names.length > 0"
class="mt-3 rounded-xl border border-base-200 bg-base-100"
>
<p class="px-4 pt-3 text-sm font-medium text-base-content/70">
Éléments concernés :
</p>
<ul class="max-h-48 divide-y divide-base-200 overflow-y-auto px-4 pb-3">
<li
v-for="(name, i) in checkResult.names"
:key="i"
class="py-1.5 text-sm text-base-content"
>
{{ name }}
</li>
</ul>
</div>
<div v-if="convertError" class="mt-3 text-sm text-error">
{{ convertError }}
</div>
</template>
<div class="modal-action">
<button
type="button"
class="btn"
:disabled="converting"
@click="emit('close')"
>
Fermer
</button>
<button
v-if="checkResult?.canConvert"
type="button"
class="btn btn-warning"
:disabled="converting"
@click="doConvert"
>
<span v-if="converting" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Convertir
</button>
</div>
</div>
</dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import IconLucideCircleX from '~icons/lucide/circle-x';
import {
checkConversion,
convertCategory,
type ConversionCheck,
type ModelType,
} from '~/services/modelTypes';
const props = defineProps<{
open: boolean;
modelType: ModelType | null;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'converted'): void;
}>();
const checking = ref(false);
const checkError = ref<string | null>(null);
const checkResult = ref<ConversionCheck | null>(null);
const converting = ref(false);
const convertError = ref<string | null>(null);
const directionLabel = computed(() => {
if (!checkResult.value) return '';
return checkResult.value.direction === 'piece_to_component'
? 'Conversion : Catégorie de pièce → Catégorie de composant'
: 'Conversion : Catégorie de composant → Catégorie de pièce';
});
watch(
() => props.open,
async (isOpen) => {
if (!isOpen || !props.modelType) {
return;
}
checking.value = true;
checkError.value = null;
checkResult.value = null;
convertError.value = null;
try {
checkResult.value = await checkConversion(props.modelType.id);
} catch (err: any) {
checkError.value =
err?.data?.message || err?.message || 'Erreur lors de la vérification.';
} finally {
checking.value = false;
}
},
);
const doConvert = async () => {
if (!props.modelType) return;
converting.value = true;
convertError.value = null;
try {
const result = await convertCategory(props.modelType.id);
if (!result.success) {
convertError.value = result.error || 'La conversion a échoué.';
return;
}
emit('converted');
} catch (err: any) {
convertError.value =
err?.data?.message || err?.message || 'Erreur lors de la conversion.';
} finally {
converting.value = false;
}
};
</script>

View File

@@ -9,189 +9,278 @@
</p> </p>
</header> </header>
<ModelTypesToolbar <nav
:category="selectedCategory" v-if="allowCategorySwitch"
:search="searchInput" class="tabs tabs-boxed inline-flex"
:sort="sort" role="tablist"
:dir="dir" aria-label="Catégories"
>
<button
v-for="option in categories"
:key="option.value"
type="button"
class="tab"
:class="{ 'tab-active': option.value === selectedCategory }"
role="tab"
:aria-selected="option.value === selectedCategory"
@click="onCategoryChange(option.value)"
>
{{ option.label }}
</button>
</nav>
<DataTable
:columns="columns"
:rows="items"
:loading="loading" :loading="loading"
:show-category-tabs="allowCategorySwitch" :sort="currentSort"
@update:category="onCategoryChange" :pagination="paginationState"
@update:search="onSearchInput" :show-per-page="true"
@update:sort="onSortChange" row-key="id"
@update:dir="onDirChange" empty-message="Aucune catégorie trouvée."
@create="openCreatePage" no-results-message="Aucune catégorie ne correspond à votre recherche."
/> @sort="handleSort"
@update:current-page="handlePageChange"
@update:per-page="handlePerPageChange"
>
<template #toolbar>
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
<input
v-model="searchInput"
type="search"
class="grow min-w-0"
placeholder="Rechercher par nom…"
autocomplete="off"
/>
</label>
<ModelTypesTable <button
:items="items" v-if="canEdit"
:loading="loading" type="button"
:total="total" class="btn btn-primary btn-sm"
:limit="limit" :disabled="loading"
:offset="offset" @click="openCreatePage"
@related="openRelatedModal" >
@edit="openEditPage" <IconLucidePlus class="w-4 h-4" aria-hidden="true" />
@delete="confirmDelete" Créer
@update:offset="onOffsetChange" </button>
/> </template>
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }"> <template #cell-name="{ row }">
<div class="modal-box max-w-3xl"> <span class="font-medium">{{ row.name }}</span>
<h3 class="text-lg font-bold text-base-content"> </template>
{{ relatedModalTitle }}
</h3>
<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"> <template #cell-notes="{ row }">
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info"> <span v-if="row.notes" class="block text-sm text-base-content/80 break-words">{{ row.notes }}</span>
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> <span v-else class="text-base-content/50"></span>
Chargement des éléments liés </template>
</div>
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error"> <template #cell-actions="{ row }">
{{ relatedError }} <div class="flex justify-end gap-2">
</div> <button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
Liés
<div </button>
v-else-if="relatedItems.length === 0" <button
class="px-4 py-6 text-sm text-base-content/60" v-if="canEdit && showConvertButton"
type="button"
class="btn btn-ghost btn-xs text-warning"
@click="openConversionModal(row)"
> >
Aucun élément lié à cette catégorie. Convertir
</div> </button>
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto"> Éditer
<li </button>
v-for="entry in relatedItems" <button v-if="canEdit" type="button" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
:key="entry.id" Supprimer
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> </button>
</div> </div>
</div> </template>
</dialog> </DataTable>
<ConversionModal
:open="conversionModalOpen"
:model-type="conversionTarget"
@close="closeConversionModal"
@converted="onConverted"
/>
<RelatedItemsModal
:open="relatedModalOpen"
:model-type="relatedType"
@close="relatedModalOpen = false"
@open-edit="openRelatedEdit"
/>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import { useHead, useRouter } from "#imports"; import { useHead, useRouter } from '#imports'
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue"; import DataTable from '~/components/common/DataTable.vue'
import ModelTypesTable from "~/components/model-types/Table.vue"; import ConversionModal from '~/components/model-types/ConversionModal.vue'
import { useApi } from "~/composables/useApi"; import { useUrlState } from '~/composables/useUrlState'
import { extractCollection } from "~/shared/utils/apiHelpers"; import type { DataTableSort } from '~/shared/types/dataTable'
import { import {
deleteModelType, deleteModelType,
listModelTypes, listModelTypes,
type ModelCategory, type ModelCategory,
type ModelType, type ModelType,
type ModelTypeListResponse, type ModelTypeListResponse,
} from "~/services/modelTypes"; } from '~/services/modelTypes'
import { useToast } from "~/composables/useToast"; import { useToast } from '~/composables/useToast'
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes"; import { humanizeError } from '~/shared/utils/errorMessages'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import IconLucideSearch from '~icons/lucide/search'
import IconLucidePlus from '~icons/lucide/plus'
const DEFAULT_DESCRIPTION = const DEFAULT_DESCRIPTION
"Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination."; = 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
category: ModelCategory; category: ModelCategory
heading: string; heading: string
description?: string; description?: string
allowCategorySwitch?: boolean; allowCategorySwitch?: boolean
}>(), }>(),
{ {
allowCategorySwitch: false, allowCategorySwitch: false,
} },
); )
const selectedCategory = ref<ModelCategory>(props.category); const selectedCategory = ref<ModelCategory>(props.category)
const searchInput = ref(""); const searchInput = ref('')
const searchTerm = ref("");
const sort = ref<"name" | "createdAt">("name");
const dir = ref<"asc" | "desc">("asc");
const limit = ref(20);
const offset = ref(0);
const items = ref<ModelType[]>([]); // State synced with URL query params
const total = ref(0); const urlState = useUrlState({
const loading = ref(false); q: { default: '' },
sort: { default: 'name' },
dir: { default: 'asc' },
limit: { default: 20, type: 'number' },
offset: { default: 0, type: 'number' },
}, {
onRestore: () => {
searchInput.value = urlState.q.value
doRefresh()
},
})
const searchTerm = urlState.q
const sort = urlState.sort as Ref<'name' | 'createdAt'>
const dir = urlState.dir as Ref<'asc' | 'desc'>
const limit = urlState.limit
const offset = urlState.offset
let debounceTimer: ReturnType<typeof setTimeout> | null = null; // Initialize searchInput from URL
let activeController: AbortController | null = null; searchInput.value = searchTerm.value
const router = useRouter(); const items = ref<ModelType[]>([])
const { showError, showSuccess } = useToast(); const total = ref(0)
const { get } = useApi(); const loading = ref(false)
const headingText = computed(() => props.heading); let debounceTimer: ReturnType<typeof setTimeout> | null = null
const descriptionText = computed( let activeController: AbortController | null = null
() => props.description ?? DEFAULT_DESCRIPTION
);
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false);
useHead(() => ({ const router = useRouter()
title: headingText.value, const { showError, showSuccess } = useToast()
})); const { canEdit } = usePermissions()
const extractErrorMessage = (error: unknown) => { const headingText = computed(() => props.heading)
if (error && typeof error === "object") { const descriptionText = computed(() => props.description ?? DEFAULT_DESCRIPTION)
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false)
useHead(() => ({ title: headingText.value }))
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'notes', label: 'Notes' },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
]
const showConvertButton = computed(() =>
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
)
const categories: Array<{ label: string, value: ModelCategory }> = [
{ label: 'Composants', value: 'COMPONENT' },
{ label: 'Pièces', value: 'PIECE' },
{ label: 'Produits', value: 'PRODUCT' },
]
// Sort state for DataTable
const currentSort = computed<DataTableSort>(() => ({
field: sort.value,
direction: dir.value,
}))
const handleSort = (newSort: DataTableSort) => {
sort.value = newSort.field as 'name' | 'createdAt'
dir.value = newSort.direction as 'asc' | 'desc'
offset.value = 0
doRefresh()
}
// Pagination: convert offset/limit to page-based for DataTable
const currentPage = computed(() => {
if (limit.value <= 0) return 1
return Math.floor(offset.value / limit.value) + 1
})
const totalPages = computed(() => {
if (limit.value <= 0) return 1
return Math.max(1, Math.ceil(total.value / limit.value))
})
const paginationState = computed(() => ({
currentPage: currentPage.value,
totalPages: totalPages.value,
totalItems: total.value,
pageItems: items.value.length,
perPageOptions: [20, 50, 100],
perPage: limit.value,
}))
const handlePageChange = (page: number) => {
offset.value = (page - 1) * limit.value
doRefresh()
}
const handlePerPageChange = (perPage: number) => {
limit.value = perPage
offset.value = 0
doRefresh()
}
const extractErrorMessage = (error: unknown): string => {
let raw: string | null = null
if (error && typeof error === 'object') {
const maybeFetchError = error as { const maybeFetchError = error as {
data?: Record<string, unknown>; data?: Record<string, unknown>
statusMessage?: string; statusMessage?: string
message?: string; message?: string
}; }
if (maybeFetchError.data) { if (maybeFetchError.data) {
const data = maybeFetchError.data; const data = maybeFetchError.data
if (typeof data.message === "string") { if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
return data.message; else if (typeof data.detail === 'string') raw = data.detail
} else if (typeof data.message === 'string') raw = data.message
if (Array.isArray(data.message) && data.message.length > 0) { else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0]
return data.message[0]; else if (typeof data.error === 'string') raw = data.error
}
}
if (typeof maybeFetchError.statusMessage === "string") {
return maybeFetchError.statusMessage;
}
if (typeof maybeFetchError.message === "string") {
return maybeFetchError.message;
} }
if (!raw && typeof maybeFetchError.statusMessage === 'string') raw = maybeFetchError.statusMessage
if (!raw && typeof maybeFetchError.message === 'string') raw = maybeFetchError.message
} }
return "Une erreur est survenue lors de la communication avec le serveur."; return humanizeError(raw)
}; }
const refresh = async ({ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
resetOffset = false, if (resetOffset) offset.value = 0
}: { resetOffset?: boolean } = {}) => {
if (resetOffset) {
offset.value = 0;
}
if (activeController) { if (activeController) activeController.abort()
activeController.abort(); const controller = new AbortController()
} activeController = controller
loading.value = true
const controller = new AbortController();
activeController = controller;
loading.value = true;
try { try {
const response: ModelTypeListResponse = await listModelTypes( const response: ModelTypeListResponse = await listModelTypes(
@@ -203,292 +292,147 @@ const refresh = async ({
limit: limit.value, limit: limit.value,
offset: offset.value, offset: offset.value,
}, },
{ signal: controller.signal } { signal: controller.signal },
); )
items.value = response.items
items.value = response.items; total.value = response.total
total.value = response.total; offset.value = response.offset
offset.value = response.offset; limit.value = response.limit
limit.value = response.limit; }
} catch (error: unknown) { catch (error: unknown) {
if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") { if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
return; showError(extractErrorMessage(error))
} }
showError(extractErrorMessage(error)); finally {
} finally {
if (activeController === controller) { if (activeController === controller) {
loading.value = false; loading.value = false
activeController = null; activeController = null
} }
} }
}; }
watch( watch(
() => props.category, () => props.category,
(value) => { (value) => {
if (value !== selectedCategory.value) { if (value !== selectedCategory.value) {
selectedCategory.value = value; selectedCategory.value = value
refresh({ resetOffset: true }); doRefresh({ resetOffset: true })
} }
} },
); )
const onSearchInput = (value: string) => {
searchInput.value = value;
};
const onCategoryChange = (value: ModelCategory) => { const onCategoryChange = (value: ModelCategory) => {
if (!allowCategorySwitch.value) { if (!props.allowCategorySwitch) return
return;
}
if (selectedCategory.value !== value) { if (selectedCategory.value !== value) {
selectedCategory.value = value; selectedCategory.value = value
refresh({ resetOffset: true }); doRefresh({ resetOffset: true })
} }
}; }
const onSortChange = (value: "name" | "createdAt") => {
if (sort.value !== value) {
sort.value = value;
refresh({ resetOffset: true });
}
};
const onDirChange = (value: "asc" | "desc") => {
if (dir.value !== value) {
dir.value = value;
refresh({ resetOffset: true });
}
};
const onOffsetChange = (value: number) => {
const nextOffset = Math.max(0, value);
if (nextOffset !== offset.value) {
offset.value = nextOffset;
refresh();
}
};
const resolveCategoryBasePath = (category: ModelCategory) => { const resolveCategoryBasePath = (category: ModelCategory) => {
if (category === "COMPONENT") { if (category === 'COMPONENT') return '/component-category'
return "/component-category"; if (category === 'PIECE') return '/piece-category'
} return '/product-category'
if (category === "PIECE") { }
return "/piece-category";
}
return "/product-category";
};
const openCreatePage = () => { const openCreatePage = () => {
const basePath = resolveCategoryBasePath(selectedCategory.value); const basePath = resolveCategoryBasePath(selectedCategory.value)
router.push(`${basePath}/new`).catch(() => { router.push(`${basePath}/new`).catch(() => {
showError("Navigation impossible vers la page de création."); showError('Navigation impossible vers la page de création.')
}); })
}; }
const openEditPage = (item: ModelType) => { const openEditPage = (item: ModelType) => {
const category = item.category ?? selectedCategory.value; const category = item.category ?? selectedCategory.value
const basePath = resolveCategoryBasePath(category); const basePath = resolveCategoryBasePath(category)
router.push(`${basePath}/${item.id}/edit`).catch(() => { router.push(`${basePath}/${item.id}/edit`).catch(() => {
showError("Navigation impossible vers la page d'édition."); showError("Navigation impossible vers la page d'édition.")
}); })
}; }
const { confirm } = useConfirm() const { confirm } = useConfirm()
const confirmDelete = async (item: ModelType) => { const confirmDelete = async (item: ModelType) => {
const confirmed = await confirm({ const confirmed = await confirm({
message: 'Supprimer ce type ? Cette action est irréversible.', message: 'Supprimer ce type ? Cette action est irréversible.',
}); })
if (!confirmed) { if (!confirmed) return
return;
}
try { try {
await deleteModelType(item.id); await deleteModelType(item.id)
invalidateEntityTypeCache(item.category); invalidateEntityTypeCache(item.category)
showSuccess(`Type « ${item.name} » supprimé avec succès.`); showSuccess(`Type « ${item.name} » supprimé avec succès.`)
if (items.value.length === 1 && offset.value >= limit.value) { if (items.value.length === 1 && offset.value >= limit.value) {
offset.value = Math.max(0, offset.value - limit.value); offset.value = Math.max(0, offset.value - limit.value)
} }
await doRefresh()
await refresh();
} catch (error) {
showError(extractErrorMessage(error));
} }
}; catch (error) {
showError(extractErrorMessage(error))
type RelatedEntry = {
id: string;
name: string;
reference?: string | null;
};
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 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 relatedModalOpen = ref(false)
const current = relatedType.value; const relatedType = ref<ModelType | null>(null)
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") { if (category === 'COMPONENT') return '/component'
return "/component"; if (category === 'PIECE') return '/pieces'
} return '/product'
if (category === "PIECE") { }
return "/pieces";
}
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) { if (!current) return
return; const basePath = resolveRelatedEditBasePath(current.category)
} relatedModalOpen.value = false
const basePath = resolveRelatedEditBasePath(current.category);
relatedModalOpen.value = false;
router.push(`${basePath}/${entry.id}/edit`).catch(() => { router.push(`${basePath}/${entry.id}/edit`).catch(() => {
showError("Navigation impossible vers la fiche d'édition."); showError("Navigation impossible vers la fiche d'édition.")
}); })
}; }
const closeRelatedModal = () => { const conversionModalOpen = ref(false)
relatedModalOpen.value = false; const conversionTarget = ref<ModelType | null>(null)
};
const openConversionModal = (item: ModelType) => {
conversionTarget.value = item
conversionModalOpen.value = true
}
const closeConversionModal = () => {
conversionModalOpen.value = false
}
const onConverted = () => {
conversionModalOpen.value = false
invalidateEntityTypeCache('PIECE')
invalidateEntityTypeCache('COMPONENT')
showSuccess('Catégorie convertie avec succès.')
doRefresh()
}
watch( watch(
() => searchInput.value, () => searchInput.value,
(value) => { (value) => {
if (debounceTimer) { if (debounceTimer) clearTimeout(debounceTimer)
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
searchTerm.value = value.trim(); searchTerm.value = value.trim()
refresh({ resetOffset: true }); doRefresh({ resetOffset: true })
}, 300); }, 300)
} },
); )
onMounted(() => { onMounted(() => {
refresh(); doRefresh()
}); })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (debounceTimer) { if (debounceTimer) clearTimeout(debounceTimer)
clearTimeout(debounceTimer); if (activeController) activeController.abort()
} })
if (activeController) {
activeController.abort();
}
});
</script> </script>

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>
@@ -29,7 +28,7 @@
class="select select-bordered w-full" class="select select-bordered w-full"
name="category" name="category"
required required
:disabled="lockCategory" :disabled="lockCategory || isReadonly"
> >
<option value="COMPONENT">Composants</option> <option value="COMPONENT">Composants</option>
<option value="PIECE">Pièces</option> <option value="PIECE">Pièces</option>
@@ -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,35 +103,16 @@
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
v-if="restrictedMode && restrictedModeMessage"
class="alert alert-info"
role="status"
aria-live="polite"
>
<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')">
Annuler Annuler
</button> </button>
<button type="submit" class="btn btn-primary" :disabled="isSubmitDisabled"> <button v-if="!isReadonly" type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span> <span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
{{ submitLabel }} {{ submitLabel }}
</button> </button>
@@ -172,10 +150,7 @@ const props = withDefaults(defineProps<{
structureLoading?: boolean structureLoading?: boolean
allowComponentSubcomponents?: boolean allowComponentSubcomponents?: boolean
componentSubcomponentMaxDepth?: number componentSubcomponentMaxDepth?: number
disableSubmit?: boolean readonly?: boolean
disableSubmitMessage?: string
restrictedMode?: boolean
restrictedModeMessage?: string
}>(), { }>(), {
initialData: null, initialData: null,
saving: false, saving: false,
@@ -183,10 +158,7 @@ const props = withDefaults(defineProps<{
structureLoading: false, structureLoading: false,
allowComponentSubcomponents: true, allowComponentSubcomponents: true,
componentSubcomponentMaxDepth: 1, componentSubcomponentMaxDepth: 1,
disableSubmit: false, readonly: false,
disableSubmitMessage: '',
restrictedMode: false,
restrictedModeMessage: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -203,18 +175,7 @@ const componentSubcomponentMaxDepth = computed(() =>
? props.componentSubcomponentMaxDepth ? props.componentSubcomponentMaxDepth
: 1, : 1,
) )
const disableSubmit = computed(() => props.disableSubmit === true) const isReadonly = computed(() => props.readonly === 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 restrictedMode = computed(() => props.restrictedMode === true)
const restrictedModeMessage = computed(() =>
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
? props.restrictedModeMessage
: '',
)
const form = reactive<ModelTypePayload>({ const form = reactive<ModelTypePayload>({
name: '', name: '',
@@ -291,7 +252,7 @@ const resetForm = () => {
} }
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) const isSubmitDisabled = computed(() => saving.value || structureLoading.value || isReadonly.value)
const validate = () => { const validate = () => {
errors.name = undefined errors.name = undefined
@@ -308,6 +269,7 @@ const validate = () => {
} }
const handleSubmit = () => { const handleSubmit = () => {
if (isReadonly.value) return
if (!validate()) { if (!validate()) {
return return
} }

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

@@ -48,10 +48,19 @@
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés Liés
</button> </button>
<button
v-if="canEdit && showConvertButton"
type="button"
class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)"
>
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
Convertir
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer Éditer
</button> </button>
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)"> <button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
Supprimer Supprimer
</button> </button>
</td> </td>
@@ -78,10 +87,19 @@
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés Liés
</button> </button>
<button
v-if="canEdit && showConvertButton"
type="button"
class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)"
>
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
Convertir
</button>
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)"> <button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
Éditer Éditer
</button> </button>
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)"> <button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
Supprimer Supprimer
</button> </button>
</footer> </footer>
@@ -118,6 +136,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import IconLucideInbox from '~icons/lucide/inbox'; import IconLucideInbox from '~icons/lucide/inbox';
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
import type { ModelType, ModelCategory } from '~/services/modelTypes'; import type { ModelType, ModelCategory } from '~/services/modelTypes';
const props = defineProps<{ const props = defineProps<{
@@ -126,15 +145,22 @@ const props = defineProps<{
total: number; total: number;
limit: number; limit: number;
offset: number; offset: number;
category?: ModelCategory;
canEdit?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'related', item: ModelType): void; (e: 'related', item: ModelType): void;
(e: 'edit', item: ModelType): void; (e: 'edit', item: ModelType): void;
(e: 'delete', item: ModelType): void; (e: 'delete', item: ModelType): void;
(e: 'convert', item: ModelType): void;
(e: 'update:offset', offset: number): void; (e: 'update:offset', offset: number): void;
}>(); }>();
const showConvertButton = computed(() =>
props.category === 'PIECE' || props.category === 'COMPONENT',
);
const categoryDictionary: Record<ModelCategory, string> = { const categoryDictionary: Record<ModelCategory, string> = {
COMPONENT: 'Composants', COMPONENT: 'Composants',
PIECE: 'Pièces', PIECE: 'Pièces',

View File

@@ -83,13 +83,14 @@ import type { ModelCategory } from '~/services/modelTypes';
type SortField = 'name' | 'createdAt'; type SortField = 'name' | 'createdAt';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
const props = defineProps<{ const props = defineProps<{
category: ModelCategory; category: ModelCategory;
search: string; search: string;
sort: SortField; sort: SortField;
dir: SortDirection; dir: SortDirection;
loading?: boolean; loading?: boolean;
showCategoryTabs?: boolean; showCategoryTabs?: boolean;
canEdit?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{

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>
@@ -37,9 +47,9 @@
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-outline" @click="emit('edit', site)"> <button class="btn btn-sm btn-outline" @click="emit('edit', site)">
Modifier {{ canEdit ? 'Modifier' : 'Consulter' }}
</button> </button>
<button class="btn btn-sm btn-error" @click="emit('delete', site)"> <button v-if="canEdit" class="btn btn-sm btn-error" @click="emit('delete', site)">
Supprimer Supprimer
</button> </button>
</div> </div>
@@ -55,6 +65,8 @@ import IconLucidePhone from '~icons/lucide/phone'
import IconLucideUser from '~icons/lucide/user' import IconLucideUser from '~icons/lucide/user'
import { formatPhone } from '~/utils/formatters/phone' import { formatPhone } from '~/utils/formatters/phone'
const { canEdit } = usePermissions()
const props = defineProps({ const props = defineProps({
site: { site: {
type: Object, type: Object,

View File

@@ -9,11 +9,12 @@
type="text" type="text"
placeholder="Nom et prénom" placeholder="Nom et prénom"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
/> />
</div> </div>
<FieldPhone v-model="contactPhone" required /> <FieldPhone v-model="contactPhone" :disabled="disabled" required />
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -24,6 +25,7 @@
type="text" type="text"
placeholder="Adresse complète" placeholder="Adresse complète"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
/> />
</div> </div>
@@ -38,6 +40,7 @@
type="text" type="text"
placeholder="Code postal" placeholder="Code postal"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
/> />
</div> </div>
@@ -51,6 +54,7 @@
type="text" type="text"
placeholder="Ville" placeholder="Ville"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
/> />
</div> </div>
@@ -77,6 +81,10 @@ const props = defineProps({
type: Object as PropType<SiteForm>, type: Object as PropType<SiteForm>,
required: true, required: true,
}, },
disabled: {
type: Boolean,
default: false,
},
}) })
const form = toRef(props, 'form') const form = toRef(props, 'form')

View File

@@ -12,17 +12,58 @@
type="text" type="text"
placeholder="Ex: Usine principale" placeholder="Ex: Usine principale"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
/> />
</div> </div>
<SiteContactFormFields :form="siteRef" /> <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" />
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" @click="emit('close')"> <button type="button" class="btn" @click="emit('close')">
Annuler Annuler
</button> </button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary" :disabled="disabled">
Créer le site Créer le site
</button> </button>
</div> </div>
@@ -38,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
@@ -53,6 +95,10 @@ const props = defineProps({
site: { site: {
type: Object as PropType<SiteForm>, type: Object as PropType<SiteForm>,
required: true required: true
},
disabled: {
type: Boolean,
default: false
} }
}) })

View File

@@ -2,8 +2,8 @@
<div v-if="visible" class="modal modal-open"> <div v-if="visible" class="modal modal-open">
<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">
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">
@@ -15,11 +15,52 @@
type="text" type="text"
placeholder="Nom du site" placeholder="Nom du site"
class="input input-bordered" class="input input-bordered"
:disabled="disabled"
required required
> >
</div> </div>
<SiteContactFormFields :form="props.form" /> <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" />
<div class="border-t border-base-200 pt-4 space-y-4"> <div class="border-t border-base-200 pt-4 space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -27,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>
@@ -37,6 +78,7 @@
</div> </div>
<DocumentUpload <DocumentUpload
v-if="!disabled"
v-model="selectedFilesModel" v-model="selectedFilesModel"
title="Déposer vos fichiers" title="Déposer vos fichiers"
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..." subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
@@ -55,8 +97,8 @@
<div class="flex items-center gap-3 text-sm"> <div class="flex items-center gap-3 text-sm">
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"> <div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center">
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -72,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>
@@ -90,7 +132,7 @@
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)"> <button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
Télécharger Télécharger
</button> </button>
<button type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)"> <button v-if="!disabled" type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
Supprimer Supprimer
</button> </button>
</div> </div>
@@ -103,7 +145,7 @@
<button type="button" class="btn" @click="emit('close')"> <button type="button" class="btn" @click="emit('close')">
Annuler Annuler
</button> </button>
<button type="submit" class="btn btn-primary" :disabled="uploadingDocuments"> <button type="submit" class="btn btn-primary" :disabled="disabled || uploadingDocuments">
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" /> <span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
Enregistrer Enregistrer
</button> </button>
@@ -114,7 +156,7 @@
</template> </template>
<script setup> <script setup>
import { computed, toRefs } from 'vue' import { computed } from 'vue'
import { isImageDocument } from '~/utils/documentPreview' import { isImageDocument } from '~/utils/documentPreview'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue' import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
@@ -155,6 +197,10 @@ const props = defineProps({
formatSize: { formatSize: {
type: Function, type: Function,
required: true required: true
},
disabled: {
type: Boolean,
default: false
} }
}) })
@@ -167,8 +213,6 @@ const emit = defineEmits([
'update:selectedFiles' 'update:selectedFiles'
]) ])
const form = toRefs(props.form)
const selectedFilesModel = computed({ const selectedFilesModel = computed({
get: () => props.selectedFiles, get: () => props.selectedFiles,
set: value => emit('update:selectedFiles', value) set: value => emit('update:selectedFiles', value)

View File

@@ -0,0 +1,70 @@
import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
export type ActivityLogActor = {
id: string
label: string
}
export type ActivityLogEntry = {
id: string
entityType: string
entityId: string
entityName: string | null
entityRef: string | null
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: ActivityLogActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
interface LoadActivityLogOptions {
page?: number
itemsPerPage?: number
entityType?: string
action?: string
}
export function useActivityLog() {
const { get } = useApi()
const entries = ref<ActivityLogEntry[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const loadActivityLog = async (options: LoadActivityLogOptions = {}) => {
loading.value = true
error.value = null
try {
const params = new URLSearchParams()
params.set('page', String(options.page ?? 1))
params.set('itemsPerPage', String(options.itemsPerPage ?? 30))
if (options.entityType) params.set('entityType', options.entityType)
if (options.action) params.set('action', options.action)
const result = await get(`/activity-logs?${params.toString()}`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger le journal d\'activité.'
entries.value = []
return result
}
const data = result.data as any
entries.value = Array.isArray(data?.items) ? data.items : []
total.value = typeof data?.total === 'number' ? data.total : entries.value.length
return { success: true, data: entries.value }
} catch (err: any) {
const message = err?.message ?? 'Erreur inconnue'
error.value = message
entries.value = []
return { success: false, error: message }
} finally {
loading.value = false
}
}
return { entries, total, loading, error, loadActivityLog }
}

View File

@@ -0,0 +1,80 @@
import { ref } from 'vue'
import { useApi } from './useApi'
export interface AdminProfile {
id: string
firstName: string
lastName: string
email: string | null
isActive: boolean
hasPassword: boolean
roles: string[]
createdAt: string
updatedAt: string
}
export function useAdminProfiles() {
const { get, post, put } = useApi()
const profiles = ref<AdminProfile[]>([])
const loading = ref(false)
const fetchAll = async () => {
loading.value = true
try {
const result = await get<AdminProfile[]>('/admin/profiles')
if (result.success && result.data) {
profiles.value = result.data
}
} finally {
loading.value = false
}
}
const createProfile = async (data: {
firstName: string
lastName: string
email?: string
password?: string
role?: string
}) => {
const result = await post<AdminProfile>('/admin/profiles', data)
if (result.success) {
await fetchAll()
}
return result
}
const updateRole = async (id: string, role: string) => {
const result = await put<AdminProfile>(`/admin/profiles/${id}/role`, { role })
if (result.success) {
await fetchAll()
}
return result
}
const setPassword = async (id: string, password: string) => {
const result = await put<AdminProfile>(`/admin/profiles/${id}/password`, { password })
if (result.success) {
await fetchAll()
}
return result
}
const deactivateProfile = async (id: string) => {
const result = await put<AdminProfile>(`/admin/profiles/${id}/deactivate`, {})
if (result.success) {
await fetchAll()
}
return result
}
return {
profiles,
loading,
fetchAll,
createProfile,
updateRole,
setPassword,
deactivateProfile,
}
}

View File

@@ -1,4 +1,5 @@
import { useToast } from './useToast' import { useToast } from './useToast'
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
export interface ApiResponse<T = any> { export interface ApiResponse<T = any> {
success: boolean success: boolean
@@ -20,11 +21,10 @@ export function useApi() {
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => { const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
const url = `${API_BASE_URL}${endpoint}` const url = `${API_BASE_URL}${endpoint}`
const isFormData = options.body instanceof FormData
const defaultOptions: ApiCallOptions = { const defaultOptions: ApiCallOptions = {
credentials: 'include', credentials: 'include',
headers: { headers: isFormData ? {} : { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
} }
// Ajouter un timeout à la requête // Ajouter un timeout à la requête
@@ -60,21 +60,26 @@ export function useApi() {
} else { } else {
const contentType = response.headers.get('content-type') || '' const contentType = response.headers.get('content-type') || ''
let errorData: Record<string, unknown> = {} let errorData: Record<string, unknown> = {}
if (contentType.includes('application/json')) { if (contentType.includes('json')) {
errorData = await response.json().catch(() => ({})) errorData = await response.json().catch(() => ({}))
} else { } else {
const text = await response.text().catch(() => '') const text = await response.text().catch(() => '')
errorData = text ? { message: text } : {} errorData = text ? { message: text } : {}
} }
const errorMessage = (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}` const rawMessage = response.status === 403
? 'Permissions insuffisantes pour cette action.'
: extractApiErrorMessage(errorData) || `Erreur ${response.status}: ${response.statusText}`
const errorMessage = humanizeError(rawMessage)
showError(errorMessage) showError(errorMessage)
return { success: false, error: errorMessage, status: response.status } return { success: false, error: errorMessage, status: response.status }
} }
} catch (error) { } catch (error) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
const err = error as Error & { name?: string } const err = error as Error & { name?: string }
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau' const errorMessage = err.name === 'AbortError'
showError(`Erreur réseau: ${errorMessage}`) ? 'La requête a pris trop de temps. Veuillez réessayer.'
: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
showError(errorMessage)
return { success: false, error: errorMessage } return { success: false, error: errorMessage }
} }
} }
@@ -113,6 +118,13 @@ export function useApi() {
}) })
} }
const postFormData = async <T = any>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, {
method: 'POST',
body: formData,
})
}
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => { const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, { method: 'DELETE' }) return apiCall<T>(endpoint, { method: 'DELETE' })
} }
@@ -121,6 +133,7 @@ export function useApi() {
apiCall, apiCall,
get, get,
post, post,
postFormData,
patch, patch,
put, put,
delete: del, delete: del,

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

@@ -0,0 +1,190 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Comment {
id: string
content: string
entityType: string
entityId: string
entityName?: string | null
authorId: string
authorName: string
status: 'open' | 'resolved'
resolvedById?: string | null
resolvedByName?: string | null
resolvedAt?: string | null
createdAt: string
updatedAt: string
}
interface CommentResult {
success: boolean
data?: Comment | Comment[]
error?: string
}
interface CommentListResult {
success: boolean
data?: Comment[]
total?: number
error?: string
}
export function useComments() {
const { get, post, patch, delete: del } = useApi()
const { showSuccess, showError } = useToast()
const loading = ref(false)
const fetchComments = async (
entityType: string,
entityId: string,
status: string = 'open',
): Promise<CommentListResult> => {
loading.value = true
try {
const params = new URLSearchParams({
entityType,
entityId,
status,
'order[createdAt]': 'desc',
itemsPerPage: '200',
})
const result = await get(`/comments?${params.toString()}`)
if (result.success) {
const items = extractCollection<Comment>(result.data)
return { success: true, data: items }
}
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const fetchAllComments = async (options: {
status?: string
entityType?: string
entityName?: string
page?: number
itemsPerPage?: number
orderBy?: string
orderDir?: string
} = {}): Promise<CommentListResult> => {
loading.value = true
try {
const params = new URLSearchParams()
if (options.status) params.set('status', options.status)
if (options.entityType) params.set('entityType', options.entityType)
if (options.entityName) params.set('entityName', options.entityName)
const sortField = options.orderBy || 'createdAt'
const sortDir = options.orderDir || 'desc'
params.set(`order[${sortField}]`, sortDir)
params.set('itemsPerPage', String(options.itemsPerPage || 30))
params.set('page', String(options.page || 1))
const result = await get(`/comments?${params.toString()}`)
if (result.success) {
const items = extractCollection<Comment>(result.data)
const raw = result.data as Record<string, unknown> | null
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
return { success: true, data: items, total }
}
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const createComment = async (
entityType: string,
entityId: string,
content: string,
entityName?: string,
): Promise<CommentResult> => {
loading.value = true
try {
const payload: Record<string, string> = { entityType, entityId, content }
if (entityName) payload.entityName = entityName
const result = await post('/comments', payload)
if (result.success) {
showSuccess('Commentaire ajouté')
return { success: true, data: result.data as Comment }
}
if (result.error) showError(result.error)
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
showError('Impossible d\'ajouter le commentaire')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const resolveComment = async (commentId: string): Promise<CommentResult> => {
loading.value = true
try {
const result = await patch(`/comments/${commentId}/resolve`)
if (result.success) {
showSuccess('Commentaire résolu')
return { success: true, data: result.data as Comment }
}
if (result.error) showError(result.error)
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
showError('Impossible de résoudre le commentaire')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const deleteComment = async (commentId: string): Promise<CommentResult> => {
loading.value = true
try {
const result = await del(`/comments/${commentId}`)
if (result.success) {
showSuccess('Commentaire supprimé')
return { success: true }
}
if (result.error) showError(result.error)
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
showError('Impossible de supprimer le commentaire')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
const fetchUnresolvedCount = async (): Promise<number> => {
try {
const result = await get<{ count: number }>('/comments/stats/unresolved-count')
if (result.success && result.data) {
return result.data.count
}
return 0
} catch {
return 0
}
}
return {
loading,
fetchComments,
fetchAllComments,
createComment,
resolveComment,
deleteComment,
fetchUnresolvedCount,
}
}

View File

@@ -0,0 +1,417 @@
/**
* 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 { uniqueConstructeurIds } 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 { 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 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(
() => 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
}
if (creationForm.constructeurIds.length) {
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
}
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?.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 = []
}
toast.showSuccess('Composant créé avec succès')
await router.push('/component-catalog')
}
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,
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,560 @@
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 type { SelectionEntry } from '~/shared/utils/structureSelectionUtils'
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 { useComponentHistory } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } 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, loadComposants, composants: componentCatalogRef } = useComposants()
const { pieces, loadPieces } = usePieces()
const { products, loadProducts } = useProducts()
const { ensureConstructeurs } = useConstructeurs()
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 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 selection entries (for selectors) ---
const pieceSlotEntries = computed(() => {
const structure = component.value?.structure
if (!structure?.pieces) return []
return (structure.pieces as any[]).map((slot: any, i: number) => ({
slotId: slot.slotId,
typePieceId: slot.typePieceId,
selectedPieceId: slot.selectedPieceId ?? null,
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) => ({
slotId: slot.slotId,
typeProductId: slot.typeProductId,
selectedProductId: slot.selectedProductId ?? 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) => ({
slotId: slot.slotId,
typeComposantId: slot.typeComposantId,
selectedComponentId: slot.selectedComponentId ?? null,
alias: slot.alias,
familyCode: slot.familyCode,
position: slot.position ?? i,
label: slot.alias || `Sous-composant #${i + 1}`,
}))
})
const savePieceSlotSelection = async (slotId: string, selectedPieceId: string | null) => {
const result = await patch(`/composant-piece-slots/${slotId}`, { selectedPieceId })
if (result.success) {
const structure = component.value?.structure
if (structure?.pieces) {
const slot = (structure.pieces as any[]).find((s: any) => s.slotId === slotId)
if (slot) slot.selectedPieceId = selectedPieceId
}
toast.showSuccess('Pièce mise à jour')
}
}
const saveProductSlotSelection = async (slotId: string, selectedProductId: string | null) => {
const result = await patch(`/composant-product-slots/${slotId}`, { selectedProductId })
if (result.success) {
const structure = component.value?.structure
if (structure?.products) {
const slot = (structure.products as any[]).find((s: any) => s.slotId === slotId)
if (slot) slot.selectedProductId = selectedProductId
}
toast.showSuccess('Produit mis à jour')
}
}
const saveSubcomponentSlotSelection = async (slotId: string, selectedComposantId: string | null) => {
const result = await patch(`/composant-subcomponent-slots/${slotId}`, { selectedComposantId })
if (result.success) {
const structure = component.value?.structure
if (structure?.subcomponents) {
const slot = (structure.subcomponents as any[]).find((s: any) => s.slotId === slotId)
if (slot) slot.selectedComponentId = selectedComposantId
}
toast.showSuccess('Sous-composant mis à jour')
}
}
const saveSlotQuantity = async (entry: SelectionEntry) => {
const slotId = entry.slotId
const quantity = typeof entry._definition?.quantity === 'number'
? Math.max(1, entry._definition.quantity)
: null
if (!slotId || quantity === null) return
try {
await patch(`/composant-piece-slots/${slotId}`, { quantity })
toast.showSuccess('Quantité mise à jour')
}
catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la mise à jour de la quantité')
}
}
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
payload.constructeurIds = uniqueConstructeurIds(editionForm.constructeurIds)
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?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await router.push('/component-catalog')
}
}
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 || ''
editionForm.constructeurIds = uniqueConstructeurIds(
currentComponent,
Array.isArray(currentComponent.constructeurs) ? currentComponent.constructeurs : [],
currentComponent.constructeur ? [currentComponent.constructeur] : [],
)
editionForm.prix = currentComponent.prix !== null && currentComponent.prix !== undefined ? String(currentComponent.prix) : ''
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
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
// Load catalogs for slot selectors (force: true to bypass cache from list pages that load fewer items)
Promise.allSettled([
loadPieces({ itemsPerPage: 200, force: true }),
loadProducts({ itemsPerPage: 200, force: true }),
loadComposants({ itemsPerPage: 200, force: true }),
]).catch(() => {})
})
return {
// State
component,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
componentDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
customFieldInputs,
historyFieldLabels,
// Computed
canEdit,
canSubmit,
componentTypeList,
selectedType,
selectedTypeStructure,
structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
// History
history,
historyLoading,
historyError,
// Methods
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
refreshDocuments,
submitEdition,
saveSlotQuantity,
savePieceSlotSelection,
saveProductSlotSelection,
saveSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
}
}

View File

@@ -10,6 +10,7 @@ export interface Composant {
id: string id: string
name: string name: string
reference?: string | null reference?: string | null
description?: string | null
typeComposantId?: string | null typeComposantId?: string | null
typeComposant?: { id: string; name?: string } | null typeComposant?: { id: string; name?: string } | null
productId?: string | null productId?: string | null
@@ -40,11 +41,14 @@ interface LoadComposantsOptions {
itemsPerPage?: number itemsPerPage?: number
orderBy?: string orderBy?: string
orderDir?: 'asc' | 'desc' orderDir?: 'asc' | 'desc'
typeName?: string
force?: boolean
} }
const composants = ref<Composant[]>([]) const composants = ref<Composant[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => { const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null const p = payload as Record<string, unknown> | null
@@ -98,15 +102,32 @@ export function useComposants() {
} }
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => { const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
typeName,
force = false,
} = options
if (!force && loaded.value && !search && !typeName && page === 1) {
return {
success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage },
}
}
if (loading.value) {
return {
success: true,
data: { items: composants.value, total: total.value, page, itemsPerPage },
}
}
loading.value = true loading.value = true
try { try {
const {
search = '',
page = 1,
itemsPerPage = 30,
orderBy = 'name',
orderDir = 'asc',
} = options
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage)) params.set('itemsPerPage', String(itemsPerPage))
@@ -116,6 +137,10 @@ export function useComposants() {
params.set('name', search.trim()) params.set('name', search.trim())
} }
if (typeName && typeName.trim()) {
params.set('typeComposant.name', typeName.trim())
}
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/composants?${params.toString()}`) const result = await get(`/composants?${params.toString()}`)
@@ -124,6 +149,7 @@ export function useComposants() {
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
composants.value = enrichedItems composants.value = enrichedItems
total.value = extractTotal(result.data, items.length) total.value = extractTotal(result.data, items.length)
loaded.value = true
return { return {
success: true, success: true,
data: { data: {
@@ -216,15 +242,23 @@ export function useComposants() {
const getComposants = () => composants.value const getComposants = () => composants.value
const isLoading = () => loading.value const isLoading = () => loading.value
const clearComposantsCache = () => {
composants.value = []
total.value = 0
loaded.value = false
}
return { return {
composants, composants,
total, total,
loading, loading,
loaded,
loadComposants, loadComposants,
createComposant, createComposant,
updateComposant: updateComposantData, updateComposant: updateComposantData,
deleteComposant, deleteComposant,
getComposants, getComposants,
isLoading, isLoading,
clearComposantsCache,
} }
} }

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

@@ -0,0 +1,186 @@
import { ref, computed, type Ref, type ComputedRef } from 'vue'
import { useUrlState } from './useUrlState'
import type { DataTableSort, DataTablePagination, DataTableColumnFilters, SortDirection } from '~/shared/types/dataTable'
export interface UseDataTableDeps {
/** Called whenever sort/page/search/perPage/filter changes. The composable does NOT fetch data itself. */
fetchData: () => void | Promise<void>
}
export interface UseDataTableOptions {
/** Default sort field */
defaultSort?: string
/** Default sort direction */
defaultDirection?: SortDirection
/** Default items per page */
defaultPerPage?: number
/** Available per-page options */
perPageOptions?: number[]
/** Search debounce in ms. Default: 300 */
searchDebounceMs?: number
/** Whether to persist state to URL. Default: true */
persistToUrl?: boolean
/** Extra URL state params for page-specific filters */
extraParams?: Record<string, { default: string | number; type?: 'string' | 'number' }>
}
export interface UseDataTableReturn {
searchTerm: Ref<string>
sortField: Ref<string>
sortDirection: Ref<SortDirection>
currentPage: Ref<number>
itemsPerPage: Ref<number>
columnFilters: Ref<DataTableColumnFilters>
filters: Record<string, Ref<string | number>>
sort: ComputedRef<DataTableSort>
pagination: (total: Ref<number>, pageItems: Ref<number>) => ComputedRef<DataTablePagination>
handleSort: (newSort: DataTableSort) => void
handlePageChange: (page: number) => void
handlePerPageChange: (perPage: number) => void
handleFilterChange: () => void
handleColumnFiltersChange: (filters: DataTableColumnFilters) => void
debouncedSearch: () => void
refresh: () => void
perPageOptions: number[]
}
export function useDataTable(
deps: UseDataTableDeps,
options: UseDataTableOptions = {},
): UseDataTableReturn {
const {
defaultSort = 'name',
defaultDirection = 'asc',
defaultPerPage = 20,
perPageOptions = [20, 50, 100],
searchDebounceMs = 300,
persistToUrl = true,
extraParams = {},
} = options
let searchTerm: Ref<string>
let sortField: Ref<string>
let sortDirection: Ref<SortDirection>
let currentPage: Ref<number>
let itemsPerPage: Ref<number>
const filters: Record<string, Ref<string | number>> = {}
if (persistToUrl) {
const paramDefs: Record<string, { default: string | number; type?: 'string' | 'number'; debounce?: number }> = {
page: { default: 1, type: 'number' },
perPage: { default: defaultPerPage, type: 'number' },
q: { default: '', debounce: searchDebounceMs },
sort: { default: defaultSort },
dir: { default: defaultDirection },
...extraParams,
}
const state = useUrlState(paramDefs, {
onRestore: () => deps.fetchData(),
})
searchTerm = state.q as Ref<string>
sortField = state.sort as Ref<string>
sortDirection = state.dir as unknown as Ref<SortDirection>
currentPage = state.page as unknown as Ref<number>
itemsPerPage = state.perPage as unknown as Ref<number>
for (const key of Object.keys(extraParams)) {
filters[key] = (state as Record<string, Ref<string | number>>)[key]!
}
}
else {
searchTerm = ref('')
sortField = ref(defaultSort)
sortDirection = ref(defaultDirection) as Ref<SortDirection>
currentPage = ref(1)
itemsPerPage = ref(defaultPerPage)
for (const [key, def] of Object.entries(extraParams)) {
filters[key] = ref(def.default)
}
}
// Search debounce
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
currentPage.value = 1
deps.fetchData()
}, searchDebounceMs)
}
// Sort
const sort = computed<DataTableSort>(() => ({
field: sortField.value,
direction: sortDirection.value,
}))
const handleSort = (newSort: DataTableSort) => {
sortField.value = newSort.field
sortDirection.value = newSort.direction
currentPage.value = 1
deps.fetchData()
}
// Pagination
const handlePageChange = (page: number) => {
currentPage.value = page
deps.fetchData()
}
const handlePerPageChange = (perPage: number) => {
itemsPerPage.value = perPage
currentPage.value = 1
deps.fetchData()
}
// Column filters
const columnFilters = ref<DataTableColumnFilters>({})
const handleColumnFiltersChange = (newFilters: DataTableColumnFilters) => {
columnFilters.value = newFilters
currentPage.value = 1
deps.fetchData()
}
// Generic filter change handler (resets page and refetches)
const handleFilterChange = () => {
currentPage.value = 1
deps.fetchData()
}
const pagination = (total: Ref<number>, pageItems: Ref<number>): ComputedRef<DataTablePagination> =>
computed(() => ({
currentPage: currentPage.value,
totalPages: Math.ceil(total.value / itemsPerPage.value) || 1,
totalItems: total.value,
pageItems: pageItems.value,
perPageOptions,
perPage: itemsPerPage.value,
}))
const refresh = () => deps.fetchData()
return {
searchTerm,
sortField,
sortDirection,
currentPage,
itemsPerPage,
columnFilters,
filters,
sort,
pagination,
handleSort,
handlePageChange,
handlePerPageChange,
handleFilterChange,
handleColumnFiltersChange,
debouncedSearch,
refresh,
perPageOptions,
}
}

View File

@@ -1,7 +1,6 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from './useApi' import { useApi } from './useApi'
import { useToast } from './useToast' import { useToast } from './useToast'
import { normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Document { export interface Document {
@@ -10,12 +9,21 @@ export interface Document {
filename: string filename: string
mimeType: string mimeType: string
size: number size: number
path: string fileUrl: string
downloadUrl: string
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
path?: string
createdAt?: string
siteId?: string siteId?: string
machineId?: string machineId?: string
composantId?: string composantId?: string
productId?: string productId?: string
pieceId?: string pieceId?: string
site?: { id: string; name?: string } | null
machine?: { id: string; name?: string } | null
composant?: { id: string; name?: string } | null
piece?: { id: string; name?: string } | null
product?: { id: string; name?: string } | null
} }
export interface UploadContext { export interface UploadContext {
@@ -32,28 +40,40 @@ export interface DocumentResult {
error?: string error?: string
} }
const documents = ref<Document[]>([]) interface LoadDocumentsOptions {
const loading = ref(false) search?: string
page?: number
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
attachmentFilter?: string
force?: boolean
}
const fileToBase64 = (file: File): Promise<string> => const documents = ref<Document[]>([])
new Promise((resolve, reject) => { const total = ref(0)
const reader = new FileReader() const loading = ref(false)
reader.onload = () => resolve(reader.result as string) const loaded = ref(false)
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
reader.readAsDataURL(file) const extractTotal = (payload: unknown, fallbackLength: number): number => {
}) const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') return p.totalItems
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
return fallbackLength
}
export function useDocuments() { export function useDocuments() {
const { get, post, delete: del } = useApi() const { get, postFormData, delete: del } = useApi()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const loadFromEndpoint = async ( const loadFromEndpoint = async (
endpoint: string, endpoint: string,
{ updateStore = false }: { updateStore?: boolean } = {}, { updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
): Promise<DocumentResult> => { ): Promise<DocumentResult> => {
loading.value = true loading.value = true
try { try {
const result = await get(endpoint) const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
const result = await get(url)
if (result.success) { if (result.success) {
const data = extractCollection(result.data) const data = extractCollection(result.data)
if (updateStore) { if (updateStore) {
@@ -75,10 +95,61 @@ export function useDocuments() {
} }
} }
const loadDocuments = async ( const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
options: { updateStore?: boolean } = {}, const {
): Promise<DocumentResult> => { search = '',
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true }) page = 1,
itemsPerPage = 30,
orderBy = 'createdAt',
orderDir = 'desc',
attachmentFilter = 'all',
force = false,
} = options
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all') {
return { success: true, data: documents.value }
}
if (loading.value) {
return { success: true, data: documents.value }
}
loading.value = true
try {
const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page))
if (search && search.trim()) {
params.set('name', search.trim())
}
if (attachmentFilter && attachmentFilter !== 'all') {
params.set(`exists[${attachmentFilter}]`, 'true')
}
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/documents?${params.toString()}`)
if (result.success) {
const items = extractCollection(result.data)
documents.value = items
total.value = extractTotal(result.data, items.length)
loaded.value = true
return { success: true, data: items }
}
if (result.error) {
showError(result.error)
}
return result as DocumentResult
} catch (error) {
const err = error as Error
console.error('Erreur lors du chargement des documents:', error)
showError('Impossible de charger les documents')
return { success: false, error: err.message }
} finally {
loading.value = false
}
} }
const loadDocumentsBySite = async ( const loadDocumentsBySite = async (
@@ -144,18 +215,17 @@ export function useDocuments() {
try { try {
for (const file of files) { for (const file of files) {
const dataUrl = await fileToBase64(file) const formData = new FormData()
formData.append('file', file)
formData.append('name', file.name)
const payload = normalizeRelationIds({ if (context.siteId) formData.append('siteId', context.siteId)
name: file.name, if (context.machineId) formData.append('machineId', context.machineId)
filename: file.name, if (context.composantId) formData.append('composantId', context.composantId)
mimeType: file.type || 'application/octet-stream', if (context.productId) formData.append('productId', context.productId)
size: file.size, if (context.pieceId) formData.append('pieceId', context.pieceId)
path: dataUrl,
...context,
})
const result = await post('/documents', payload) const result = await postFormData('/documents', formData)
if (result.success) { if (result.success) {
created.push(result.data as Document) created.push(result.data as Document)
showSuccess(`Document "${file.name}" ajouté`) showSuccess(`Document "${file.name}" ajouté`)
@@ -212,7 +282,9 @@ export function useDocuments() {
return { return {
documents, documents,
total,
loading, loading,
loaded,
loadDocuments, loadDocuments,
loadDocumentsBySite, loadDocumentsBySite,
loadDocumentsByMachine, loadDocumentsByMachine,

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

@@ -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 })

View File

@@ -7,6 +7,7 @@
import { ref, type Ref } from 'vue' import { ref, type Ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { import {
listModelTypes, listModelTypes,
createModelType, createModelType,
@@ -102,8 +103,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
return { success: true, data: state.types.value } return { success: true, data: state.types.value }
} catch (error) { } catch (error) {
const err = error as Error & { message?: string } const err = error as Error & { message?: string }
const message = err?.message || 'Erreur inconnue' const message = humanizeError(err?.message)
showError(`Impossible de charger les types de ${label}: ${message}`) showError(`Impossible de charger les types de ${label}.`)
return { success: false, error: message } return { success: false, error: message }
} finally { } finally {
state.loading.value = false state.loading.value = false
@@ -127,8 +128,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
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?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue' const raw = err?.data?.message || err?.message
showError(`Erreur lors de la création du type de ${label}: ${message}`) const message = humanizeError(raw)
showError(`Impossible de créer le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }
} finally { } finally {
state.loading.value = false state.loading.value = false
@@ -152,8 +154,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
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?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue' const raw = err?.data?.message || err?.message
showError(`Erreur lors de la mise à jour du type de ${label}: ${message}`) const message = humanizeError(raw)
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }
} finally { } finally {
state.loading.value = false state.loading.value = false
@@ -169,8 +172,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
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?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue' const raw = err?.data?.message || err?.message
showError(`Erreur lors de la suppression du type de ${label}: ${message}`) const message = humanizeError(raw)
showError(`Impossible de supprimer le type de ${label} : ${message}`)
return { success: false, error: message } return { success: false, error: message }
} finally { } finally {
state.loading.value = false state.loading.value = false

View File

@@ -1,46 +1,23 @@
/** /**
* Machine creation page orchestration composable. * Machine creation page orchestration composable.
* *
* Consolidates entity lookup maps, option filters, label helpers, * Simplified: no more TypeMachine / skeleton system.
* template wrappers, and the finalization logic that were previously * Supports direct creation or cloning from an existing machine.
* inlined in pages/machines/new.vue.
*/ */
import { ref, reactive, computed, watch, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines' import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
import { useComposants } from '~/composables/useComposants'
import { usePieces } from '~/composables/usePieces'
import { useProducts } from '~/composables/useProducts'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections' import { humanizeError } from '~/shared/utils/errorMessages'
import {
useMachineCreatePreview,
validateRequirementSelections as _validateRequirementSelections,
resolveComponentRequirementTypeLabel as _resolveComponentRequirementTypeLabel,
resolvePieceRequirementTypeLabel as _resolvePieceRequirementTypeLabel,
} from '~/composables/useMachineCreatePreview'
import {
getComponentMachineAssignments,
getPieceMachineAssignments,
getPieceComponentAssignments,
formatAssignmentList,
} from '~/shared/utils/assignmentUtils'
export function useMachineCreatePage() { export function useMachineCreatePage() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Composable calls // Composable calls
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines() const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
const { sites, loadSites } = useSites() const { sites, loadSites } = useSites()
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
const { composants, loadComposants, loading: composantsLoading } = useComposants()
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
const { products, loadProducts, loading: productsLoading } = useProducts()
const { get } = useApi()
const toast = useToast() const toast = useToast()
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -48,348 +25,84 @@ export function useMachineCreatePage() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const submitting = ref(false) const submitting = ref(false)
const loading = ref(true)
const newMachine = reactive({ const newMachine = reactive({
name: '', name: '',
siteId: '', siteId: '',
typeMachineId: '',
reference: '', reference: '',
cloneFromMachineId: '',
}) })
const selectedMachineType = computed(() => {
if (!newMachine.typeMachineId) return null
return (machineTypes as any).value.find((type: any) => type.id === newMachine.typeMachineId) || null
})
// ---------------------------------------------------------------------------
// Entity lookup maps
// ---------------------------------------------------------------------------
const componentById = computed(() => {
const map = new Map()
;((composants as any).value || []).forEach((component: any) => {
if (component?.id) map.set(component.id, component)
})
return map
})
const pieceById = computed(() => {
const map = new Map()
;((pieces as any).value || []).forEach((piece: any) => {
if (piece?.id) map.set(piece.id, piece)
})
return map
})
const componentInventory = computed(() => (composants as any).value || [])
const pieceInventory = computed(() => (pieces as any).value || [])
const productInventory = computed(() => (products as any).value || [])
const productById = computed(() => {
const map = new Map()
;(productInventory.value || []).forEach((product: any) => {
if (product?.id) map.set(product.id, product)
})
return map
})
// ---------------------------------------------------------------------------
// Entity finders
// ---------------------------------------------------------------------------
const findComponentById = (id: string) => {
if (!id) return null
return componentById.value.get(id) || null
}
const findPieceById = (id: string): any => {
if (!id) return null
return pieceById.value.get(id) || findPieceInCachedOptions(id) || null
}
const findProductById = (id: string) => {
if (!id) return null
return productById.value.get(id) || null
}
// ---------------------------------------------------------------------------
// Selection state (from composable)
// ---------------------------------------------------------------------------
const {
pieceOptionsByKey,
pieceLoadingByKey,
selectedPieceIds,
getPieceKey,
findPieceInCachedOptions,
fetchPieceOptions,
getComponentRequirementEntries,
getPieceRequirementEntries,
getProductRequirementEntries,
addComponentSelectionEntry,
removeComponentSelectionEntry,
addPieceSelectionEntry,
removePieceSelectionEntry,
addProductSelectionEntry,
removeProductSelectionEntry,
setComponentRequirementComponent,
setPieceRequirementPiece,
setProductRequirementProduct: _setProductRequirementProduct,
clearRequirementSelections,
initializeRequirementSelections,
} = useMachineCreateSelections({
findComponentById,
findPieceById,
pieces: pieces as any,
get: get as any,
toast,
})
// ---------------------------------------------------------------------------
// Preview / validation (from composable)
// ---------------------------------------------------------------------------
const { machinePreview, blockingPreviewIssues, canCreateMachine } = useMachineCreatePreview({
newMachine,
sites: sites as any,
selectedMachineType,
findComponentById,
findPieceById,
findProductById,
getComponentRequirementEntries,
getPieceRequirementEntries,
getProductRequirementEntries,
})
// ---------------------------------------------------------------------------
// Template wrappers
// ---------------------------------------------------------------------------
const resolveComponentRequirementTypeLabel = (requirement: any, entry: any) =>
_resolveComponentRequirementTypeLabel(requirement, entry, findComponentById)
const resolvePieceRequirementTypeLabel = (requirement: any, entry: any) =>
_resolvePieceRequirementTypeLabel(requirement, entry, findPieceById)
const setProductRequirementProduct = (requirement: any, index: number, productId: string) =>
_setProductRequirementProduct(requirement, index, productId, findProductById)
const validateRequirementSelections = (type: any) =>
_validateRequirementSelections(type, {
newMachine,
sites: sites as any,
selectedMachineType,
findComponentById,
findPieceById,
findProductById,
getComponentRequirementEntries,
getPieceRequirementEntries,
getProductRequirementEntries,
})
// ---------------------------------------------------------------------------
// Machine type helpers
// ---------------------------------------------------------------------------
const machineTypeLabel = (type: any) => {
if (!type) return ''
return type.name || 'Type de machine'
}
const machineTypeDescription = (type: any) => {
if (!type) return ''
const parts: string[] = []
if (type.category) parts.push(`Catégorie : ${type.category}`)
const componentCount = type.componentRequirements?.length ?? 0
const pieceCount = type.pieceRequirements?.length ?? 0
const productCount = type.productRequirements?.length ?? 0
parts.push(
`${componentCount} composant(s)`,
`${pieceCount} pièce(s)`,
`${productCount} produit(s)`,
)
return parts.join(' • ')
}
// ---------------------------------------------------------------------------
// Option filters
// ---------------------------------------------------------------------------
const getComponentOptions = (requirement: any, currentEntry: any) => {
const requirementTypeId = requirement?.typeComposantId || requirement?.typeComposant?.id || null
return componentInventory.value.filter((component: any) => {
if (!component?.id) return false
if (requirementTypeId && component.typeComposantId !== requirementTypeId) {
return currentEntry?.composantId === component.id
}
return true
})
}
const getPieceOptions = (requirement: any, currentEntry: any, entryIndex: number) => {
const key = getPieceKey(requirement, entryIndex)
const cached = pieceOptionsByKey.value[key]
if (cached) return cached
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
const usedIds = new Set(
selectedPieceIds.value.filter((id: any) => id && (!currentEntry || id !== currentEntry.pieceId)),
)
return pieceInventory.value.filter((piece: any) => {
if (requirementTypeId && piece.typePieceId !== requirementTypeId) return false
if (!piece.id) return false
if (currentEntry?.pieceId === piece.id) return true
return !usedIds.has(piece.id)
})
}
const getProductOptions = (requirement: any) => {
const requirementTypeId = requirement?.typeProductId || requirement?.typeProduct?.id || null
return productInventory.value.filter((product: any) => {
if (!product?.id) return false
if (!requirementTypeId) return true
const productTypeId = product.typeProductId || product.typeProduct?.id || null
return productTypeId === requirementTypeId
})
}
// ---------------------------------------------------------------------------
// Option label / description helpers
// ---------------------------------------------------------------------------
const componentOptionLabel = (component: any) => component?.name || 'Composant'
const componentOptionDescription = (component: any) => {
if (!component) return ''
const parts: string[] = []
if (component.reference) parts.push(`Réf. ${component.reference}`)
const constructeurName = component.constructeur?.name || component.constructeurName
if (constructeurName) parts.push(constructeurName)
const machineAssignments = getComponentMachineAssignments(component)
if (machineAssignments.length) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
const productTypeName = component.product?.typeProduct?.name
const productLabel = component.product?.name || component.product?.reference
if (productTypeName || productLabel) parts.push(`Produit: ${productTypeName || productLabel}`)
return parts.join(' • ')
}
const pieceOptionLabel = (piece: any) => piece?.name || 'Pièce'
const pieceOptionDescription = (piece: any) => {
if (!piece) return ''
const parts: string[] = []
if (piece.reference) parts.push(`Réf. ${piece.reference}`)
const constructeurName = piece.constructeur?.name || piece.constructeurName
if (constructeurName) parts.push(constructeurName)
const machineAssignments = getPieceMachineAssignments(piece)
if (machineAssignments.length) parts.push(`Machines: ${formatAssignmentList(machineAssignments)}`)
const componentAssignments = getPieceComponentAssignments(piece)
if (componentAssignments.length) parts.push(`Composants: ${formatAssignmentList(componentAssignments)}`)
const productTypeName = piece.product?.typeProduct?.name
const productLabel = piece.product?.name || piece.product?.reference
if (productTypeName || productLabel) parts.push(`Produit: ${productTypeName || productLabel}`)
return parts.join(' • ')
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Machine creation // Machine creation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const finalizeMachineCreation = async () => { const finalizeMachineCreation = async () => {
if (submitting.value) return if (submitting.value) return
const type = selectedMachineType.value
if (!type) { if (!newMachine.name?.trim()) {
toast.showError('Merci de sélectionner un type de machine') toast.showError('Merci de renseigner un nom pour la machine')
return
}
if (!canCreateMachine.value) {
toast.showError('Compléter les informations obligatoires avant de créer la machine')
return return
} }
submitting.value = true submitting.value = true
try { try {
const baseMachineData = { let result: any
name: newMachine.name,
siteId: newMachine.siteId, if (newMachine.cloneFromMachineId) {
reference: newMachine.reference, result = await cloneMachine(newMachine.cloneFromMachineId, {
typeMachineId: type.id, name: newMachine.name,
siteId: newMachine.siteId,
...(newMachine.reference ? { reference: newMachine.reference } : {}),
})
} else {
result = await createMachine({
name: newMachine.name,
siteId: newMachine.siteId || undefined,
reference: newMachine.reference || undefined,
} as any)
} }
const hasRequirements =
(type.componentRequirements?.length || 0) > 0 ||
(type.pieceRequirements?.length || 0) > 0 ||
(type.productRequirements?.length || 0) > 0
let componentLinks: any[] = []
let pieceLinks: any[] = []
let productLinks: any[] = []
if (hasRequirements) {
const validationResult = validateRequirementSelections(type)
if (!validationResult.valid) {
toast.showError(validationResult.error as string)
return
}
componentLinks = validationResult.componentLinks as any[]
pieceLinks = validationResult.pieceLinks as any[]
productLinks = validationResult.productLinks as any[]
}
const result: any = hasRequirements
? await createMachine(baseMachineData as any)
: await createMachineFromType(baseMachineData as any, type)
if (result.success) { if (result.success) {
if (hasRequirements && result.data?.id) { const machineId = result.data?.id
const skeletonResult: any = await reconfigureSkeleton(result.data.id, { || (result.data?.machine as any)?.id
componentLinks, || null
pieceLinks,
productLinks,
} as any)
if (!skeletonResult.success) {
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
return
}
}
newMachine.name = '' newMachine.name = ''
newMachine.siteId = '' newMachine.siteId = ''
newMachine.typeMachineId = ''
newMachine.reference = '' newMachine.reference = ''
clearRequirementSelections() newMachine.cloneFromMachineId = ''
await navigateTo('/machines')
if (machineId) {
await navigateTo(`/machine/${machineId}`)
} else {
await navigateTo('/machines')
}
} else if (result.error) { } else if (result.error) {
toast.showError(`Impossible de créer la machine: ${result.error}`) toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
} }
} catch (error: any) { } catch (error: any) {
toast.showError(`Erreur lors de la création: ${error.message}`) toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
} finally { } finally {
submitting.value = false submitting.value = false
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Watchers & lifecycle // Lifecycle
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
watch(
() => newMachine.typeMachineId,
(typeId) => {
clearRequirementSelections()
if (!typeId) return
const type = (machineTypes as any).value.find((item: any) => item.id === typeId)
if (!type) return
initializeRequirementSelections(type)
},
)
onMounted(async () => { onMounted(async () => {
await Promise.all([ loading.value = true
loadSites(), try {
loadMachineTypes(), await Promise.all([
loadComposants(), loadSites(),
loadPieces(), loadMachines(),
loadProducts(), ])
]) } finally {
loading.value = false
}
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -398,59 +111,11 @@ export function useMachineCreatePage() {
return { return {
// State // State
submitting,
newMachine, newMachine,
sites, sites,
machineTypes, machines,
machineTypesLoading, submitting,
composantsLoading, loading,
piecesLoading,
productsLoading,
selectedMachineType,
// Selection state
pieceLoadingByKey,
getPieceKey,
fetchPieceOptions,
getComponentRequirementEntries,
getPieceRequirementEntries,
getProductRequirementEntries,
addComponentSelectionEntry,
removeComponentSelectionEntry,
addPieceSelectionEntry,
removePieceSelectionEntry,
addProductSelectionEntry,
removeProductSelectionEntry,
setComponentRequirementComponent,
setPieceRequirementPiece,
setProductRequirementProduct,
// Preview
machinePreview,
blockingPreviewIssues,
canCreateMachine,
// Entity finders
findComponentById,
findPieceById,
findProductById,
// Options
getComponentOptions,
getPieceOptions,
getProductOptions,
// Label helpers
machineTypeLabel,
machineTypeDescription,
componentOptionLabel,
componentOptionDescription,
pieceOptionLabel,
pieceOptionDescription,
// Type label resolvers
resolveComponentRequirementTypeLabel,
resolvePieceRequirementTypeLabel,
// Actions // Actions
finalizeMachineCreation, finalizeMachineCreation,

View File

@@ -1,572 +0,0 @@
/**
* Machine creation preview computation and validation.
*
* Extracted from pages/machines/new.vue. Builds the live preview model
* and validates requirement selections before machine creation.
*/
import { computed, type Ref, type ComputedRef } from 'vue'
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
import { extractParentLinkIdentifiers } from '~/shared/utils/productDisplayUtils'
import {
getComponentMachineAssignments,
getPieceMachineAssignments,
getPieceComponentAssignments,
formatAssignmentList,
} from '~/shared/utils/assignmentUtils'
type AnyRecord = Record<string, unknown>
export interface MachineCreatePreviewDeps {
newMachine: { name: string; siteId: string; typeMachineId: string; reference: string }
sites: Ref<AnyRecord[]>
selectedMachineType: ComputedRef<AnyRecord | null>
findComponentById: (id: string) => AnyRecord | null
findPieceById: (id: string) => AnyRecord | null
findProductById: (id: string) => AnyRecord | null
getComponentRequirementEntries: (requirementId: string) => AnyRecord[]
getPieceRequirementEntries: (requirementId: string) => AnyRecord[]
getProductRequirementEntries: (requirementId: string) => AnyRecord[]
}
// ---------------------------------------------------------------------------
// Product type ID extractors
// ---------------------------------------------------------------------------
const getProductTypeIdFromComponent = (component: AnyRecord | null): string | null => {
if (!component || typeof component !== 'object') return null
return (
(component.product as AnyRecord)?.typeProductId ||
((component.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
component.productTypeId ||
null
) as string | null
}
const getProductTypeIdFromPiece = (piece: AnyRecord | null): string | null => {
if (!piece || typeof piece !== 'object') return null
return (
(piece.product as AnyRecord)?.typeProductId ||
((piece.product as AnyRecord)?.typeProduct as AnyRecord)?.id ||
piece.productTypeId ||
null
) as string | null
}
// ---------------------------------------------------------------------------
// Status badge helper
// ---------------------------------------------------------------------------
export const getStatusBadgeClass = (status: string): string => {
if (status === 'ready') return 'badge-success'
if (status === 'warning') return 'badge-warning'
return 'badge-error'
}
// ---------------------------------------------------------------------------
// Scroll / issue click helpers
// ---------------------------------------------------------------------------
const highlightClasses = ['ring', 'ring-primary', 'ring-offset-2']
export const scrollToAnchor = (anchor: string): void => {
if (!anchor || typeof window === 'undefined' || typeof document === 'undefined') return
const target = document.getElementById(anchor)
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
highlightClasses.forEach((cls) => target.classList.add(cls))
window.setTimeout(() => {
highlightClasses.forEach((cls) => target.classList.remove(cls))
}, 1500)
}
export const handleIssueClick = (issue: AnyRecord): void => {
if (!issue?.anchor) return
scrollToAnchor(issue.anchor as string)
}
// ---------------------------------------------------------------------------
// Type label resolvers
// ---------------------------------------------------------------------------
export const resolveComponentRequirementTypeLabel = (
requirement: AnyRecord,
entry: AnyRecord,
findComponentById: (id: string) => AnyRecord | null,
): string => {
if (entry?.composantId) {
const component = findComponentById(entry.composantId as string)
if ((component?.typeComposant as AnyRecord)?.name) {
return (component!.typeComposant as AnyRecord).name as string
}
}
return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
}
export const resolvePieceRequirementTypeLabel = (
requirement: AnyRecord,
entry: AnyRecord,
findPieceById: (id: string) => AnyRecord | null,
): string => {
if (entry?.pieceId) {
const piece = findPieceById(entry.pieceId as string)
if ((piece?.typePiece as AnyRecord)?.name) {
return (piece!.typePiece as AnyRecord).name as string
}
}
return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
}
// ---------------------------------------------------------------------------
// Product requirement stats
// ---------------------------------------------------------------------------
const computeProductUsageFromSelections = (
type: AnyRecord,
deps: MachineCreatePreviewDeps,
): Map<string, number> => {
const usage = new Map<string, number>()
const increment = (typeProductId: string | null) => {
if (!typeProductId) return
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
}
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
const entries = deps.getComponentRequirementEntries(requirement.id as string)
entries.forEach((entry) => {
if (!entry?.composantId) return
const component = deps.findComponentById(entry.composantId as string)
increment(getProductTypeIdFromComponent(component))
})
}
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
const entries = deps.getPieceRequirementEntries(requirement.id as string)
entries.forEach((entry) => {
if (!entry?.pieceId) return
const piece = deps.findPieceById(entry.pieceId as string)
increment(getProductTypeIdFromPiece(piece))
})
}
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
const entries = deps.getProductRequirementEntries(requirement.id as string)
entries.forEach((entry) => {
if (!entry?.productId) return
const product = deps.findProductById(entry.productId as string)
const typeProductId = (
product?.typeProductId ||
(product?.typeProduct as AnyRecord)?.id ||
entry?.typeProductId ||
requirement?.typeProductId ||
(requirement?.typeProduct as AnyRecord)?.id ||
null
) as string | null
increment(typeProductId)
})
}
return usage
}
const buildProductRequirementStats = (
type: AnyRecord,
deps: MachineCreatePreviewDeps,
): { stats: AnyRecord[]; usage: Map<string, number> } => {
const usage = computeProductUsageFromSelections(type, deps)
const stats = ((type.productRequirements || []) as AnyRecord[]).map((requirement) => {
const typeProductId = (
requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null
) as string | null
const label = (
(requirement.label as string)?.trim() ||
(requirement.typeProduct as AnyRecord)?.name ||
(requirement.typeProduct as AnyRecord)?.code ||
'Produit requis'
) as string
const typeName = ((requirement.typeProduct as AnyRecord)?.name || 'Non défini') as string
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const max = (requirement.maxCount ?? null) as number | null
const count = typeProductId ? usage.get(typeProductId) ?? 0 : 0
const rawEntries = deps.getProductRequirementEntries(requirement.id as string)
const normalizedEntries = rawEntries.map((entry, index) => {
const product = entry?.productId ? deps.findProductById(entry.productId as string) : null
const subtitleParts: string[] = []
if (product?.reference) subtitleParts.push(`Réf. ${product.reference}`)
if (product?.supplierPrice !== undefined && product?.supplierPrice !== null) {
const price = Number(product.supplierPrice)
if (!Number.isNaN(price)) subtitleParts.push(`${price.toFixed(2)}`)
}
if (Array.isArray(product?.constructeurs) && (product!.constructeurs as AnyRecord[]).length) {
const cLabel = (product!.constructeurs as AnyRecord[])
.map((c) => c?.name)
.filter(Boolean)
.join(', ')
if (cLabel) subtitleParts.push(`Fournisseurs: ${cLabel}`)
}
return {
key: `${requirement.id}-${index}`,
status: product ? 'complete' : 'pending',
title: (product?.name || product?.reference || `Sélection #${index + 1}`) as string,
subtitle: subtitleParts.length ? subtitleParts.join(' • ') : null,
}
})
const issues: AnyRecord[] = []
if (count < min) {
issues.push({
message: `Le produit "${label}" nécessite au moins ${min} sélection(s). Actuellement ${count}.`,
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
if (max !== null && count > max) {
issues.push({
message: `Le produit "${label}" ne peut pas dépasser ${max} sélection(s). Actuellement ${count}.`,
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
if (normalizedEntries.length > 0 && normalizedEntries.some((e) => e.status !== 'complete')) {
issues.push({
message: 'Sélectionner un produit pour chaque entrée ajoutée.',
kind: 'error',
anchor: `product-group-${requirement.id}`,
})
}
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
const total = normalizedEntries.length
const status = issues.some((i) => i.kind === 'error')
? 'error'
: issues.some((i) => i.kind === 'warning')
? 'warning'
: 'ready'
return {
id: requirement.id,
requirement,
label,
typeName,
count,
min,
max,
completed,
total,
entries: normalizedEntries,
issues,
allowNewModels: requirement.allowNewModels ?? true,
status,
}
})
return { stats, usage }
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
export const validateRequirementSelections = (
type: AnyRecord,
deps: MachineCreatePreviewDeps,
): AnyRecord => {
const errors: string[] = []
const componentLinksPayload: AnyRecord[] = []
const pieceLinksPayload: AnyRecord[] = []
const productLinksPayload: AnyRecord[] = []
for (const requirement of (type.componentRequirements || []) as AnyRecord[]) {
const entries = deps.getComponentRequirementEntries(requirement.id as string)
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const max = (requirement.maxCount ?? null) as number | null
if (entries.length < min) {
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
}
if (max !== null && entries.length > max) {
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
}
entries.forEach((entry) => {
if (!entry.composantId) {
errors.push(`Sélectionner un composant existant pour "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}".`)
return
}
const component = deps.findComponentById(entry.composantId as string)
if (!component) {
errors.push(`Le composant sélectionné est introuvable (ID: ${entry.composantId}).`)
return
}
const requiredTypeId = (requirement.typeComposantId || (requirement.typeComposant as AnyRecord)?.id || null) as string | null
if (requiredTypeId && component.typeComposantId && component.typeComposantId !== requiredTypeId) {
errors.push(`Le composant "${component.name || component.id}" n'appartient pas à la famille attendue.`)
return
}
const payload: AnyRecord = { requirementId: requirement.id, composantId: entry.composantId }
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
if (overrides) payload.overrides = overrides
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
componentLinksPayload.push(payload)
})
}
for (const requirement of (type.pieceRequirements || []) as AnyRecord[]) {
const entries = deps.getPieceRequirementEntries(requirement.id as string)
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const max = (requirement.maxCount ?? null) as number | null
if (entries.length < min) {
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
}
if (max !== null && entries.length > max) {
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
}
entries.forEach((entry) => {
if (!entry.pieceId) {
errors.push(`Sélectionner une pièce existante pour "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}".`)
return
}
const piece = deps.findPieceById(entry.pieceId as string)
if (!piece) {
errors.push(`La pièce sélectionnée est introuvable (ID: ${entry.pieceId}).`)
return
}
const requiredTypeId = (requirement.typePieceId || (requirement.typePiece as AnyRecord)?.id || null) as string | null
if (requiredTypeId && piece.typePieceId && piece.typePieceId !== requiredTypeId) {
errors.push(`La pièce "${piece.name || piece.id}" n'appartient pas à la famille attendue.`)
return
}
const payload: AnyRecord = { requirementId: requirement.id, pieceId: entry.pieceId }
const overrides = sanitizeDefinitionOverrides(entry.definition as AnyRecord)
if (overrides) payload.overrides = overrides
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
pieceLinksPayload.push(payload)
})
}
const { stats: productStats } = buildProductRequirementStats(type, deps)
for (const requirement of (type.productRequirements || []) as AnyRecord[]) {
const entries = deps.getProductRequirementEntries(requirement.id as string)
const max = (requirement.maxCount ?? null) as number | null
if (max !== null && entries.length > max) {
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} entrée(s) directe(s).`)
}
entries.forEach((entry) => {
if (!entry.productId) {
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
return
}
const product = deps.findProductById(entry.productId as string)
if (!product) {
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
return
}
const requiredTypeId = (requirement.typeProductId || (requirement.typeProduct as AnyRecord)?.id || null) as string | null
const productTypeId = (product.typeProductId || (product.typeProduct as AnyRecord)?.id || entry.typeProductId || null) as string | null
if (requiredTypeId && productTypeId && productTypeId !== requiredTypeId) {
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
return
}
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
productLinksPayload.push(payload)
})
}
productStats.forEach((stat) => {
((stat.issues || []) as AnyRecord[])
.filter((issue) => issue.kind === 'error')
.forEach((issue) => errors.push(issue.message as string))
})
if (errors.length > 0) return { valid: false, error: errors[0] }
return {
valid: true,
componentLinks: componentLinksPayload,
pieceLinks: pieceLinksPayload,
productLinks: productLinksPayload,
}
}
// ---------------------------------------------------------------------------
// Main preview composable
// ---------------------------------------------------------------------------
export function useMachineCreatePreview(deps: MachineCreatePreviewDeps) {
const machinePreview = computed(() => {
const type = deps.selectedMachineType.value
if (!type) return null
const trimmedName = (deps.newMachine.name || '').trim()
const currentSite = deps.newMachine.siteId
? deps.sites.value.find((site) => site.id === deps.newMachine.siteId) || null
: null
const trimmedReference = (deps.newMachine.reference || '').trim()
const baseFields = [
{ key: 'name', label: 'Nom', display: trimmedName || 'À renseigner', status: trimmedName ? 'complete' : 'missing' },
{ key: 'site', label: 'Site', display: (currentSite?.name || 'Sélectionner un site') as string, status: currentSite ? 'complete' : 'missing' },
{ key: 'type', label: 'Type sélectionné', display: type.name as string, status: 'complete' },
{ key: 'reference', label: 'Référence', display: trimmedReference || 'Non renseignée', status: trimmedReference ? 'complete' : 'optional' },
]
const baseIssues: AnyRecord[] = []
if (!trimmedName) baseIssues.push({ message: 'Renseigner un nom de machine.', kind: 'error', anchor: 'machine-field-name' })
if (!currentSite) baseIssues.push({ message: "Sélectionner un site d'affectation.", kind: 'error', anchor: 'machine-field-site' })
const baseStatus = baseIssues.some((issue) => issue.kind === 'error') ? 'error' : 'ready'
// Component groups
const componentGroups = ((type.componentRequirements || []) as AnyRecord[]).map((requirement) => {
const entries = deps.getComponentRequirementEntries(requirement.id as string)
const normalizedEntries = entries.map((entry, index) => {
const selectedComponent = entry.composantId ? deps.findComponentById(entry.composantId as string) : null
const displayName = (selectedComponent?.name || (requirement.typeComposant as AnyRecord)?.name || 'Composant') as string
const subtitleParts: string[] = []
if (selectedComponent?.reference) subtitleParts.push(`Réf. ${selectedComponent.reference}`)
const constructeurName = (selectedComponent?.constructeur as AnyRecord)?.name || selectedComponent?.constructeurName
if (constructeurName) subtitleParts.push(constructeurName as string)
const machineAssignments = selectedComponent ? getComponentMachineAssignments(selectedComponent) : []
const assignmentLabel = formatAssignmentList(machineAssignments)
if (assignmentLabel) subtitleParts.push(`Liée à ${assignmentLabel}`)
return {
key: `${requirement.id}-${index}`,
status: entry.composantId ? 'complete' : 'pending',
title: displayName,
subtitle: subtitleParts.join(' • ') || null,
assignmentLabel,
assignments: machineAssignments,
}
})
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const max = (requirement.maxCount ?? null) as number | null
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
const issues: AnyRecord[] = []
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `component-group-${requirement.id}` })
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `component-group-${requirement.id}` })
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner un composant pour chaque entrée.', kind: 'error', anchor: `component-group-${requirement.id}` })
const hasErrors = issues.some((i) => i.kind === 'error')
const hasWarnings = completed < entries.length
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
return {
id: requirement.id,
label: (requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Famille de composants') as string,
typeName: ((requirement.typeComposant as AnyRecord)?.name || 'Non défini') as string,
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
}
})
// Piece groups
const pieceGroups = ((type.pieceRequirements || []) as AnyRecord[]).map((requirement) => {
const entries = deps.getPieceRequirementEntries(requirement.id as string)
const normalizedEntries = entries.map((entry, index) => {
const selectedPiece = entry.pieceId ? deps.findPieceById(entry.pieceId as string) : null
const displayName = (selectedPiece?.name || (requirement.typePiece as AnyRecord)?.name || 'Pièce') as string
const subtitleParts: string[] = []
if (selectedPiece?.reference) subtitleParts.push(`Réf. ${selectedPiece.reference}`)
const constructeurName = (selectedPiece?.constructeur as AnyRecord)?.name || selectedPiece?.constructeurName
if (constructeurName) subtitleParts.push(constructeurName as string)
const machineAssignments = selectedPiece ? getPieceMachineAssignments(selectedPiece) : []
const machineAssignmentLabel = formatAssignmentList(machineAssignments)
if (machineAssignmentLabel) subtitleParts.push(`Machines: ${machineAssignmentLabel}`)
const componentAssignments = selectedPiece ? getPieceComponentAssignments(selectedPiece) : []
const componentAssignmentLabel = formatAssignmentList(componentAssignments)
if (componentAssignmentLabel) subtitleParts.push(`Composants: ${componentAssignmentLabel}`)
return {
key: `${requirement.id}-${index}`,
status: entry.pieceId ? 'complete' : 'pending',
title: displayName,
subtitle: subtitleParts.join(' • ') || null,
machineAssignmentLabel, componentAssignmentLabel,
machineAssignments, componentAssignments,
}
})
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const max = (requirement.maxCount ?? null) as number | null
const completed = normalizedEntries.filter((e) => e.status === 'complete').length
const issues: AnyRecord[] = []
if (entries.length < min) issues.push({ message: `Minimum ${min} sélection(s) requise(s)`, kind: 'error', anchor: `piece-group-${requirement.id}` })
if (max !== null && entries.length > max) issues.push({ message: `Maximum ${max} dépassé`, kind: 'error', anchor: `piece-group-${requirement.id}` })
if (normalizedEntries.some((e) => e.status !== 'complete')) issues.push({ message: 'Sélectionner une pièce pour chaque entrée.', kind: 'error', anchor: `piece-group-${requirement.id}` })
const hasErrors = issues.some((i) => i.kind === 'error')
const hasWarnings = completed < entries.length
const status = hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready'
return {
id: requirement.id,
label: (requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Groupe de pièces') as string,
typeName: ((requirement.typePiece as AnyRecord)?.name || 'Non défini') as string,
min, max, entries: normalizedEntries, issues, completed, total: entries.length, status,
}
})
// Product groups
const { stats: productGroups } = buildProductRequirementStats(type, deps)
// Aggregate
const aggregatedIssues = [
...baseIssues.map((issue) => ({ ...issue, scope: 'Informations générales' })),
...componentGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
...pieceGroups.flatMap((group) => group.issues.map((issue) => ({ ...issue, scope: group.label }))),
...productGroups.flatMap((group: AnyRecord) => ((group.issues || []) as AnyRecord[]).map((issue) => ({ ...issue, scope: group.label }))),
]
const statuses = [
baseStatus,
...componentGroups.map((g) => g.status),
...pieceGroups.map((g) => g.status),
...productGroups.map((g: AnyRecord) => g.status as string),
]
const overallStatus = statuses.includes('error') ? 'error' : statuses.includes('warning') ? 'warning' : 'ready'
return {
base: { fields: baseFields, issues: baseIssues, status: baseStatus },
componentGroups,
pieceGroups,
productGroups,
type: {
name: type.name,
category: type.category || null,
hasStructuredDefinition:
((type.componentRequirements as unknown[])?.length || 0) > 0 ||
((type.pieceRequirements as unknown[])?.length || 0) > 0 ||
((type.productRequirements as unknown[])?.length || 0) > 0,
},
status: overallStatus,
ready: overallStatus === 'ready',
issues: aggregatedIssues,
}
})
const blockingPreviewIssues = computed(() => {
if (!machinePreview.value) return []
return (machinePreview.value.issues as AnyRecord[]).filter((issue) => issue.kind === 'error')
})
const canCreateMachine = computed(() => {
if (!machinePreview.value) return false
return blockingPreviewIssues.value.length === 0
})
return {
machinePreview,
blockingPreviewIssues,
canCreateMachine,
}
}

View File

@@ -1,365 +0,0 @@
/**
* Machine creation requirement selection state management.
*
* Extracted from pages/machines/new.vue. Manages the reactive selection state
* for component / piece / product requirements when creating a new machine.
*/
import { ref, reactive, computed } from 'vue'
import { extractCollection } from '~/shared/utils/apiHelpers'
type AnyRecord = Record<string, unknown>
export interface MachineCreateSelectionsDeps {
findComponentById: (id: string) => AnyRecord | null
findPieceById: (id: string) => AnyRecord | null
pieces: { value: AnyRecord[] }
get: (url: string) => Promise<AnyRecord>
toast: { showError: (msg: string) => void }
}
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
const { findComponentById, findPieceById, pieces, get, toast } = deps
// ---------------------------------------------------------------------------
// Reactive state
// ---------------------------------------------------------------------------
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
const pieceOptionsByKey = ref<Record<string, AnyRecord[]>>({})
const pieceLoadingByKey = ref<Record<string, boolean>>({})
// ---------------------------------------------------------------------------
// Piece option caching
// ---------------------------------------------------------------------------
const getPieceKey = (requirement: AnyRecord, entryIndex: number): string =>
`${requirement?.id || 'req'}:${entryIndex}`
const findPieceInCachedOptions = (id: string): AnyRecord | null => {
if (!id) return null
const buckets = Object.values(pieceOptionsByKey.value || {})
for (const bucket of buckets) {
if (!Array.isArray(bucket)) continue
const found = bucket.find((piece) => piece?.id === id)
if (found) return found
}
return null
}
const cachePieceIfMissing = (piece: AnyRecord): void => {
if (!piece?.id) return
const current = Array.isArray(pieces.value) ? pieces.value : []
if (current.some((p: AnyRecord) => p?.id === piece.id)) return
pieces.value = [...current, piece]
}
const fetchPieceOptions = async (
requirement: AnyRecord,
entryIndex: number,
term = '',
): Promise<void> => {
const key = getPieceKey(requirement, entryIndex)
if (pieceLoadingByKey.value[key]) return
const requirementTypeId =
(requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null) as string | null
const params = new URLSearchParams()
params.set('itemsPerPage', '50')
if (term && term.trim()) params.set('name', term.trim())
if (requirementTypeId) params.set('typePiece', `/api/model_types/${requirementTypeId}`)
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true }
try {
const result = await get(`/pieces?${params.toString()}`)
if (result.success) {
pieceOptionsByKey.value = {
...pieceOptionsByKey.value,
[key]: extractCollection(result.data) as AnyRecord[],
}
}
} finally {
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false }
}
}
// ---------------------------------------------------------------------------
// Entry getters
// ---------------------------------------------------------------------------
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
componentRequirementSelections[requirementId] || []
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
pieceRequirementSelections[requirementId] || []
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
productRequirementSelections[requirementId] || []
// ---------------------------------------------------------------------------
// Entry factories
// ---------------------------------------------------------------------------
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
typeComposantId: requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
composantId: source?.composantId || null,
definition: {},
})
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
typePieceId: requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
pieceId: source?.pieceId || null,
definition: {},
})
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => ({
typeProductId:
source?.typeProductId ||
requirement?.typeProductId ||
(requirement?.typeProduct as AnyRecord)?.id ||
null,
productId: source?.productId || null,
})
// ---------------------------------------------------------------------------
// Selected piece IDs (for dedup)
// ---------------------------------------------------------------------------
const selectedPieceIds = computed(() => {
const ids: string[] = []
Object.values(pieceRequirementSelections).forEach((entries) => {
;(entries || []).forEach((entry) => {
if (entry?.pieceId) ids.push(entry.pieceId as string)
})
})
return ids
})
// ---------------------------------------------------------------------------
// CRUD operations
// ---------------------------------------------------------------------------
const addComponentSelectionEntry = (requirement: AnyRecord): void => {
const entries = getComponentRequirementEntries(requirement.id as string)
const max = (requirement.maxCount ?? null) as number | null
if (max !== null && entries.length >= max) {
toast.showError(
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
)
return
}
componentRequirementSelections[requirement.id as string] = [
...entries,
createComponentSelectionEntry(requirement),
]
}
const removeComponentSelectionEntry = (requirementId: string, index: number): void => {
const entries = getComponentRequirementEntries(requirementId)
componentRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
}
const addPieceSelectionEntry = (requirement: AnyRecord): void => {
const entries = getPieceRequirementEntries(requirement.id as string)
const max = (requirement.maxCount ?? null) as number | null
if (max !== null && entries.length >= max) {
toast.showError(
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
)
return
}
pieceRequirementSelections[requirement.id as string] = [
...entries,
createPieceSelectionEntry(requirement),
]
fetchPieceOptions(requirement, entries.length).catch(() => {})
}
const removePieceSelectionEntry = (requirementId: string, index: number): void => {
const entries = getPieceRequirementEntries(requirementId)
pieceRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
}
const addProductSelectionEntry = (requirement: AnyRecord): void => {
const entries = getProductRequirementEntries(requirement.id as string)
const max = (requirement.maxCount ?? null) as number | null
if (max !== null && entries.length >= max) {
toast.showError(
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
)
return
}
productRequirementSelections[requirement.id as string] = [
...entries,
createProductSelectionEntry(requirement),
]
}
const removeProductSelectionEntry = (requirementId: string, index: number): void => {
const entries = getProductRequirementEntries(requirementId)
productRequirementSelections[requirementId] = entries.filter((_: unknown, i: number) => i !== index)
}
// ---------------------------------------------------------------------------
// Selection setters
// ---------------------------------------------------------------------------
const setComponentRequirementComponent = (
requirement: AnyRecord,
index: number,
componentId: string,
): void => {
const entries = getComponentRequirementEntries(requirement.id as string)
const entry = entries[index]
if (!entry) return
entry.composantId = componentId || null
if (componentId) {
const component = findComponentById(componentId)
entry.typeComposantId = component?.typeComposantId || requirement?.typeComposantId || null
} else {
entry.typeComposantId = requirement?.typeComposantId || null
}
}
const setPieceRequirementPiece = (
requirement: AnyRecord,
index: number,
pieceId: string,
): void => {
const entries = getPieceRequirementEntries(requirement.id as string)
const entry = entries[index]
if (!entry) return
entry.pieceId = pieceId || null
if (pieceId) {
const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId)
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
if (piece) cachePieceIfMissing(piece as AnyRecord)
} else {
entry.typePieceId = requirement?.typePieceId || null
}
}
const setProductRequirementProduct = (
requirement: AnyRecord,
index: number,
productId: string,
findProductById: (id: string) => AnyRecord | null,
): void => {
const entries = getProductRequirementEntries(requirement.id as string)
const entry = entries[index]
if (!entry) return
const normalizedProductId = productId || null
entry.productId = normalizedProductId
if (normalizedProductId) {
const product = findProductById(normalizedProductId)
entry.typeProductId =
product?.typeProductId ||
(product?.typeProduct as AnyRecord)?.id ||
entry.typeProductId ||
requirement?.typeProductId ||
(requirement?.typeProduct as AnyRecord)?.id ||
null
} else {
entry.typeProductId =
requirement?.typeProductId ||
(requirement?.typeProduct as AnyRecord)?.id ||
null
}
}
// ---------------------------------------------------------------------------
// Bulk operations
// ---------------------------------------------------------------------------
const clearRequirementSelections = (): void => {
Object.keys(componentRequirementSelections).forEach((key) => {
delete componentRequirementSelections[key]
})
Object.keys(pieceRequirementSelections).forEach((key) => {
delete pieceRequirementSelections[key]
})
Object.keys(productRequirementSelections).forEach((key) => {
delete productRequirementSelections[key]
})
}
const initializeRequirementSelections = (type: AnyRecord): void => {
const componentRequirements = (type.componentRequirements || []) as AnyRecord[]
const pieceRequirements = (type.pieceRequirements || []) as AnyRecord[]
const productRequirements = (type.productRequirements || []) as AnyRecord[]
componentRequirements.forEach((requirement) => {
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const initialCount = Math.max(min, requirement.required ? 1 : 0)
if (initialCount > 0) {
componentRequirementSelections[requirement.id as string] = Array.from(
{ length: initialCount },
() => createComponentSelectionEntry(requirement),
)
} else {
componentRequirementSelections[requirement.id as string] = []
}
})
pieceRequirements.forEach((requirement) => {
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const initialCount = Math.max(min, requirement.required ? 1 : 0)
if (initialCount > 0) {
const entries = Array.from(
{ length: initialCount },
() => createPieceSelectionEntry(requirement),
)
pieceRequirementSelections[requirement.id as string] = entries
entries.forEach((_: unknown, index: number) => {
fetchPieceOptions(requirement, index).catch(() => {})
})
} else {
pieceRequirementSelections[requirement.id as string] = []
}
})
productRequirements.forEach((requirement) => {
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const initialCount = Math.max(min, requirement.required ? 1 : 0)
if (initialCount > 0) {
productRequirementSelections[requirement.id as string] = Array.from(
{ length: initialCount },
() => createProductSelectionEntry(requirement),
)
} else {
productRequirementSelections[requirement.id as string] = []
}
})
}
return {
componentRequirementSelections,
pieceRequirementSelections,
productRequirementSelections,
pieceOptionsByKey,
pieceLoadingByKey,
selectedPieceIds,
getPieceKey,
findPieceInCachedOptions,
fetchPieceOptions,
getComponentRequirementEntries,
getPieceRequirementEntries,
getProductRequirementEntries,
addComponentSelectionEntry,
removeComponentSelectionEntry,
addPieceSelectionEntry,
removePieceSelectionEntry,
addProductSelectionEntry,
removeProductSelectionEntry,
setComponentRequirementComponent,
setPieceRequirementPiece,
setProductRequirementProduct,
clearRequirementSelections,
initializeRequirementSelections,
}
}

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,396 @@
/**
* 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)
}
}
return {
// State
machineCustomFields,
// Computed
visibleMachineCustomFields,
// Transform functions
transformCustomFields,
transformComponentCustomFields,
// Methods
syncMachineCustomFields,
setMachineCustomFieldValue,
updateMachineCustomField,
updatePieceCustomField,
}
}

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,233 @@
/**
* Machine detail page — update/mutation methods.
*
* Extracted from useMachineDetailData.ts to keep the orchestrator under 500 lines.
*/
import type { Ref } from 'vue'
import { uniqueConstructeurIds } 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[]>
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 }
}
export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
const {
machine,
machineName,
machineReference,
machineSiteId,
machineConstructeurIds,
machineComponentLinks,
machinePieceLinks,
applyMachineLinks,
loadProductDocuments,
transformComponentCustomFields,
transformCustomFields,
upsertCustomFieldValue,
updateMachineApi,
updateComposantApi,
updatePieceApi,
apiPatch,
toast,
} = deps
const updateMachineInfo = async () => {
if (!machine.value) return
try {
const cIds = uniqueConstructeurIds(machineConstructeurIds.value)
machineConstructeurIds.value = cIds
const result: any = await updateMachineApi(machine.value.id as string, {
name: machineName.value,
reference: machineReference.value,
siteId: machineSiteId.value || undefined,
constructeurIds: cIds,
} 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 || [],
}
machineConstructeurIds.value = uniqueConstructeurIds(
machine.value!.constructeurIds,
machine.value!.constructeurs,
machine.value!.constructeur,
)
const linksApplied = applyMachineLinks(result.data)
if (linksApplied && machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
}
loadProductDocuments().catch(() => {})
}
}
} 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 = async (value: unknown) => {
machineConstructeurIds.value = uniqueConstructeurIds(value)
await updateMachineInfo()
}
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

@@ -153,8 +153,6 @@ export const buildMachineHierarchyFromLinks = (
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
const requirement = (link.typeMachinePieceRequirement || appliedPiece.typeMachinePieceRequirement || originalPiece?.typeMachinePieceRequirement || null) as AnyRecord | null
const machinePieceLinkId = normalizePieceLinkId(link) const machinePieceLinkId = normalizePieceLinkId(link)
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId) const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
@@ -170,11 +168,8 @@ export const buildMachineHierarchyFromLinks = (
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null, constructeur: appliedPiece.constructeur || originalPiece?.constructeur || null,
constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null, constructeurId: appliedPiece.constructeurId || (appliedPiece.constructeur as AnyRecord)?.id || originalPiece?.constructeurId || null,
documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [], documents: Array.isArray(appliedPiece.documents) ? appliedPiece.documents : Array.isArray(originalPiece?.documents) ? originalPiece!.documents : [],
typePiece: appliedPiece.typePiece || requirement?.typePiece || null, typePiece: appliedPiece.typePiece || null,
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null, typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || null,
typeMachinePieceRequirement: requirement,
typeMachinePieceRequirementId: requirement?.id || null,
requirementId: requirement?.id || null,
overrides, overrides,
originalPiece, originalPiece,
machinePieceLink: link, machinePieceLink: link,
@@ -186,11 +181,9 @@ 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),
parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId), quantity: typeof link.quantity === 'number' ? link.quantity : 1,
parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
definition: appliedPiece.definition || originalPiece?.definition || {}, definition: appliedPiece.definition || originalPiece?.definition || {},
customFields: appliedPiece.customFields || [], customFields: appliedPiece.customFields || [],
skeletonOnly: !pieceId,
} }
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id) const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
@@ -215,8 +208,6 @@ export const buildMachineHierarchyFromLinks = (
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
const requirement = (link.typeMachineComponentRequirement || appliedComponent.typeMachineComponentRequirement || originalComponent?.typeMachineComponentRequirement || null) as AnyRecord | null
const machineComponentLinkId = normalizeComponentLinkId(link) const machineComponentLinkId = normalizeComponentLinkId(link)
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId) const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
@@ -224,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[]
: [] : []
@@ -245,11 +265,8 @@ export const buildMachineHierarchyFromLinks = (
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null, constructeur: appliedComponent.constructeur || originalComponent?.constructeur || null,
constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null, constructeurId: appliedComponent.constructeurId || (appliedComponent.constructeur as AnyRecord)?.id || originalComponent?.constructeurId || null,
documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [], documents: Array.isArray(appliedComponent.documents) ? appliedComponent.documents : Array.isArray(originalComponent?.documents) ? originalComponent!.documents : [],
typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null, typeComposant: appliedComponent.typeComposant || null,
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null, typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || null,
typeMachineComponentRequirement: requirement,
typeMachineComponentRequirementId: requirement?.id || null,
requirementId: requirement?.id || null,
overrides: compOverrides, overrides: compOverrides,
machineComponentLinkOverrides: compOverrides, machineComponentLinkOverrides: compOverrides,
definitionOverrides: compOverrides, definitionOverrides: compOverrides,
@@ -259,16 +276,12 @@ export const buildMachineHierarchyFromLinks = (
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),
parentRequirementId: resolveIdentifier(appliedComponent.parentRequirementId, link.parentRequirementId),
parentMachineComponentRequirementId: resolveIdentifier(appliedComponent.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
parentMachinePieceRequirementId: resolveIdentifier(appliedComponent.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
definition: appliedComponent.definition || originalComponent?.definition || {}, definition: appliedComponent.definition || originalComponent?.definition || {},
customFields: appliedComponent.customFields || [], customFields: appliedComponent.customFields || [],
pieces, pieces,
subComponents, subComponents,
subcomponents: subComponents, subcomponents: subComponents,
sousComposants: subComponents, sousComposants: subComponents,
skeletonOnly: !composantId,
} }
const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId) const constructeurs = collectConstructeurs(allConstructeurs, appliedComponent.constructeurs, appliedComponent.constructeur, appliedComponent.constructeurIds, appliedComponent.constructeurId, originalComponent?.constructeurs, originalComponent?.constructeur, originalComponent?.constructeurIds, originalComponent?.constructeurId)

View File

@@ -1,838 +0,0 @@
/**
* Machine skeleton editor — selection state, validation & save logic.
*
* Extracted from pages/machine/[id].vue (F1.1).
* Manages the reactive selection state for component / piece / product
* skeleton requirements, validation, and reconfiguration API calls.
*/
import { ref, reactive, computed } from 'vue'
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
import {
resolveIdentifier,
extractParentLinkIdentifiers,
} from '~/shared/utils/productDisplayUtils'
import {
uniqueConstructeurIds,
} from '~/shared/constructeurUtils'
import { resolveLinkArray } from '~/composables/useMachineHierarchy'
import type { Ref, ComputedRef } from 'vue'
type AnyRecord = Record<string, unknown>
export interface MachineSkeletonEditorDeps {
machine: Ref<AnyRecord | null>
components: Ref<AnyRecord[]>
pieces: Ref<AnyRecord[]>
machineComponentLinks: Ref<AnyRecord[]>
machinePieceLinks: Ref<AnyRecord[]>
machineProductLinks: Ref<AnyRecord[]>
machineType: ComputedRef<AnyRecord | null>
machineHasSkeletonRequirements: ComputedRef<boolean>
componentRequirements: ComputedRef<AnyRecord[]>
pieceRequirements: ComputedRef<AnyRecord[]>
productRequirements: ComputedRef<AnyRecord[]>
componentTypeLabelMap: ComputedRef<Map<string, string>>
pieceTypeLabelMap: ComputedRef<Map<string, string>>
productInventory: ComputedRef<AnyRecord[]>
flattenedComponents: ComputedRef<AnyRecord[]>
machinePieces: ComputedRef<AnyRecord[]>
machineDocumentsLoaded: Ref<boolean>
findProductById: (id: string | null | undefined) => AnyRecord | null
findComponentById: (items: AnyRecord[] | undefined, id: string) => AnyRecord | null
findPieceById: (id: string) => AnyRecord | null
transformCustomFields: (pieces: AnyRecord[]) => AnyRecord[]
transformComponentCustomFields: (components: AnyRecord[]) => AnyRecord[]
applyMachineLinks: (source: AnyRecord) => boolean
collapseAllComponents: () => void
initMachineFields: () => void
collectPiecesForSkeleton: () => AnyRecord[]
constructeurs: Ref<AnyRecord[]>
loadProducts: () => Promise<void>
reconfigureMachineSkeleton: (id: string, payload: AnyRecord) => Promise<AnyRecord>
toast: { showError: (msg: string) => void; showInfo: (msg: string) => void }
}
export function useMachineSkeletonEditor(deps: MachineSkeletonEditorDeps) {
const {
machine,
components,
pieces,
machineComponentLinks,
machinePieceLinks,
machineProductLinks,
machineType,
machineHasSkeletonRequirements,
productRequirements,
componentTypeLabelMap,
pieceTypeLabelMap,
productInventory,
flattenedComponents,
machineDocumentsLoaded,
findProductById,
findComponentById,
findPieceById,
transformCustomFields,
transformComponentCustomFields,
applyMachineLinks,
collapseAllComponents,
initMachineFields,
collectPiecesForSkeleton,
loadProducts,
reconfigureMachineSkeleton,
toast,
} = deps
// ---------------------------------------------------------------------------
// View state
// ---------------------------------------------------------------------------
const activeMachineView = ref<'details' | 'skeleton'>('details')
const isDetailsView = computed(() => activeMachineView.value === 'details')
const isSkeletonView = computed(() => activeMachineView.value === 'skeleton')
// ---------------------------------------------------------------------------
// Editor state
// ---------------------------------------------------------------------------
const skeletonEditor = reactive({
open: false,
loading: false,
submitting: false,
})
const componentRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
const pieceRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
const productRequirementSelections = reactive<Record<string, AnyRecord[]>>({})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const isPlainObject = (value: unknown): boolean =>
Object.prototype.toString.call(value) === '[object Object]'
const getComponentRequirementEntries = (requirementId: string): AnyRecord[] =>
componentRequirementSelections[requirementId] || []
const getPieceRequirementEntries = (requirementId: string): AnyRecord[] =>
pieceRequirementSelections[requirementId] || []
const getProductRequirementEntries = (requirementId: string): AnyRecord[] =>
productRequirementSelections[requirementId] || []
// ---------------------------------------------------------------------------
// Label resolvers
// ---------------------------------------------------------------------------
const resolveComponentRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
const typeId = (entry?.typeComposantId || requirement?.typeComposantId || null) as string | null
if (!typeId) return ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
return componentTypeLabelMap.value.get(typeId) || ((requirement?.typeComposant as AnyRecord)?.name as string) || 'Type non défini'
}
const resolvePieceRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
const typeId = (entry?.typePieceId || requirement?.typePieceId || null) as string | null
if (!typeId) return ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
return pieceTypeLabelMap.value.get(typeId) || ((requirement?.typePiece as AnyRecord)?.name as string) || 'Type non défini'
}
const resolveProductRequirementTypeLabel = (requirement: AnyRecord, entry: AnyRecord): string => {
const typeId =
(entry?.typeProductId as string) ||
(requirement?.typeProductId as string) ||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
null
if (typeId) {
const typeMatch = productRequirements.value.find(
(req: AnyRecord) =>
req.typeProductId === typeId || (req.typeProduct as AnyRecord)?.id === typeId,
)
if (typeMatch && (typeMatch.typeProduct as AnyRecord)?.name) {
return (typeMatch.typeProduct as AnyRecord).name as string
}
}
return ((requirement?.typeProduct as AnyRecord)?.name as string) || 'Catégorie non définie'
}
const getProductOptionsForRequirement = (requirement: AnyRecord): AnyRecord[] => {
const requirementTypeId =
(requirement?.typeProductId as string) ||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
null
return (productInventory.value as AnyRecord[]).filter((product) => {
if (!product?.id) return false
if (!requirementTypeId) return true
const productTypeId =
(product.typeProductId as string) ||
((product.typeProduct as AnyRecord)?.id as string) ||
null
return productTypeId === requirementTypeId
})
}
// ---------------------------------------------------------------------------
// Selection entry factories
// ---------------------------------------------------------------------------
const createComponentSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
const link = (source?.machineComponentLink as AnyRecord) || null
const entry: AnyRecord = {
linkId: resolveIdentifier(link?.id, source?.machineComponentLinkId, source?.linkId),
composantId: resolveIdentifier(source?.composantId, source?.componentId, source?.id),
parentLinkId: resolveIdentifier(link?.parentLinkId, link?.parentComponentLinkId, source?.parentComponentLinkId, source?.parentLinkId),
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId),
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
typeComposantId:
(source?.typeMachineComponentRequirement as AnyRecord)?.typeComposantId ||
source?.typeComposantId ||
(source?.typeComposant as AnyRecord)?.id ||
requirement?.typeComposantId ||
null,
definition: {
name: source?.name || source?.nom || (requirement?.typeComposant as AnyRecord)?.name || '',
reference: source?.reference || '',
constructeurIds: [] as string[],
constructeurId: null as string | null,
prix: source?.prix ?? source?.price ?? null,
},
}
const defConstructeurIds = uniqueConstructeurIds(
(link?.overrides as AnyRecord)?.constructeurIds,
(link?.overrides as AnyRecord)?.constructeurId,
source?.constructeurIds,
source?.constructeurId,
source?.constructeur,
)
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
if (link?.overrides && isPlainObject(link.overrides)) {
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
}
const finalConstructeurIds = uniqueConstructeurIds(
(entry.definition as AnyRecord).constructeurIds,
(entry.definition as AnyRecord).constructeurId,
(entry.definition as AnyRecord).constructeur,
)
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
return entry
}
const createPieceSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
const link = (source?.machinePieceLink as AnyRecord) || null
const entry: AnyRecord = {
linkId: resolveIdentifier(link?.id, source?.machinePieceLinkId, source?.linkId),
pieceId: resolveIdentifier(source?.pieceId, source?.id),
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId, source?.machineComponentLinkId),
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
parentComponentId: resolveIdentifier(link?.parentComponentId, source?.parentComponentId, source?.composantId),
parentPieceId: resolveIdentifier(link?.parentPieceId, source?.parentPieceId),
composantId: resolveIdentifier(source?.composantId, link?.composantId, link?.componentId),
typePieceId:
(source?.typeMachinePieceRequirement as AnyRecord)?.typePieceId ||
source?.typePieceId ||
(source?.typePiece as AnyRecord)?.id ||
requirement?.typePieceId ||
null,
definition: {
name: source?.name || source?.nom || (requirement?.typePiece as AnyRecord)?.name || '',
reference: source?.reference || '',
constructeurIds: [] as string[],
constructeurId: null as string | null,
prix: source?.prix ?? source?.price ?? null,
},
}
const defConstructeurIds = uniqueConstructeurIds(
(link?.overrides as AnyRecord)?.constructeurIds,
(link?.overrides as AnyRecord)?.constructeurId,
source?.constructeurIds,
source?.constructeurId,
source?.constructeur,
)
;(entry.definition as AnyRecord).constructeurIds = defConstructeurIds
;(entry.definition as AnyRecord).constructeurId = defConstructeurIds[0] || null
if (link?.overrides && isPlainObject(link.overrides)) {
entry.definition = { ...(entry.definition as AnyRecord), ...(link.overrides as AnyRecord) }
}
const finalConstructeurIds = uniqueConstructeurIds(
(entry.definition as AnyRecord).constructeurIds,
(entry.definition as AnyRecord).constructeurId,
(entry.definition as AnyRecord).constructeur,
)
;(entry.definition as AnyRecord).constructeurIds = finalConstructeurIds
;(entry.definition as AnyRecord).constructeurId = finalConstructeurIds[0] || null
return entry
}
const createProductSelectionEntry = (requirement: AnyRecord, source: AnyRecord | null = null): AnyRecord => {
const link = (source?.machineProductLink as AnyRecord) || source || null
return {
linkId: resolveIdentifier(link?.id, source?.machineProductLinkId, source?.linkId),
productId: resolveIdentifier(source?.productId, link?.productId),
parentLinkId: resolveIdentifier(link?.parentLinkId, source?.parentLinkId),
parentComponentLinkId: resolveIdentifier(link?.parentComponentLinkId, source?.parentComponentLinkId),
parentPieceLinkId: resolveIdentifier(link?.parentPieceLinkId, source?.parentPieceLinkId),
parentRequirementId: resolveIdentifier(link?.parentRequirementId, source?.parentRequirementId, requirement?.parentRequirementId),
parentComponentRequirementId: resolveIdentifier(link?.parentComponentRequirementId, source?.parentComponentRequirementId, requirement?.parentComponentRequirementId),
parentPieceRequirementId: resolveIdentifier(link?.parentPieceRequirementId, source?.parentPieceRequirementId, requirement?.parentPieceRequirementId),
parentMachineComponentRequirementId: resolveIdentifier(link?.parentMachineComponentRequirementId, source?.parentMachineComponentRequirementId, requirement?.parentMachineComponentRequirementId),
parentMachinePieceRequirementId: resolveIdentifier(link?.parentMachinePieceRequirementId, source?.parentMachinePieceRequirementId, requirement?.parentMachinePieceRequirementId),
typeProductId: resolveIdentifier(link?.typeProductId, source?.typeProductId, requirement?.typeProductId, (requirement?.typeProduct as AnyRecord)?.id),
}
}
// ---------------------------------------------------------------------------
// Selection CRUD
// ---------------------------------------------------------------------------
const resetSkeletonRequirementSelections = () => {
Object.keys(componentRequirementSelections).forEach((k) => delete componentRequirementSelections[k])
Object.keys(pieceRequirementSelections).forEach((k) => delete pieceRequirementSelections[k])
Object.keys(productRequirementSelections).forEach((k) => delete productRequirementSelections[k])
}
const addComponentSelectionEntry = (requirement: AnyRecord) => {
const entries = getComponentRequirementEntries(requirement.id as string)
const max = (requirement.maxCount as number | null) ?? null
if (max !== null && entries.length >= max) {
toast.showError(
`Vous ne pouvez pas ajouter plus de ${max} composant(s) pour ${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'ce groupe'}`,
)
return
}
componentRequirementSelections[requirement.id as string] = [
...entries,
createComponentSelectionEntry(requirement),
]
}
const removeComponentSelectionEntry = (requirementId: string, index: number) => {
const entries = getComponentRequirementEntries(requirementId)
componentRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
}
const setComponentRequirementType = (requirementId: string, index: number, value: string | null) => {
const entry = getComponentRequirementEntries(requirementId)[index]
if (!entry) return
entry.typeComposantId = value || null
}
const setComponentRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
const entry = getComponentRequirementEntries(requirementId)[index]
if (!entry) return
const ids = uniqueConstructeurIds(value)
;(entry.definition as AnyRecord).constructeurIds = ids
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
}
const addPieceSelectionEntry = (requirement: AnyRecord) => {
const entries = getPieceRequirementEntries(requirement.id as string)
const max = (requirement.maxCount as number | null) ?? null
if (max !== null && entries.length >= max) {
toast.showError(
`Vous ne pouvez pas ajouter plus de ${max} pièce(s) pour ${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'ce groupe'}`,
)
return
}
pieceRequirementSelections[requirement.id as string] = [
...entries,
createPieceSelectionEntry(requirement),
]
}
const removePieceSelectionEntry = (requirementId: string, index: number) => {
const entries = getPieceRequirementEntries(requirementId)
pieceRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
}
const setPieceRequirementType = (requirementId: string, index: number, value: string | null) => {
const entry = getPieceRequirementEntries(requirementId)[index]
if (!entry) return
entry.typePieceId = value || null
}
const setPieceRequirementConstructeur = (requirementId: string, index: number, value: unknown) => {
const entry = getPieceRequirementEntries(requirementId)[index]
if (!entry) return
const ids = uniqueConstructeurIds(value)
;(entry.definition as AnyRecord).constructeurIds = ids
;(entry.definition as AnyRecord).constructeurId = ids[0] || null
}
const addProductSelectionEntry = (requirement: AnyRecord) => {
const entries = getProductRequirementEntries(requirement.id as string)
const max = (requirement.maxCount as number | null) ?? null
if (max !== null && entries.length >= max) {
toast.showError(
`Vous ne pouvez pas ajouter plus de ${max} produit(s) pour ${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'ce groupe'}`,
)
return
}
productRequirementSelections[requirement.id as string] = [
...entries,
createProductSelectionEntry(requirement),
]
}
const removeProductSelectionEntry = (requirementId: string, index: number) => {
const entries = getProductRequirementEntries(requirementId)
productRequirementSelections[requirementId] = entries.filter((_, i) => i !== index)
}
const setProductRequirementProduct = (requirementId: string, index: number, productId: string | null) => {
const entry = getProductRequirementEntries(requirementId)[index]
if (!entry) return
const normalizedProductId = productId || null
entry.productId = normalizedProductId
if (normalizedProductId) {
const product = findProductById(normalizedProductId)
entry.typeProductId =
(product?.typeProductId as string) ||
((product?.typeProduct as AnyRecord)?.id as string) ||
(entry.typeProductId as string) ||
null
}
}
const setProductRequirementType = (requirementId: string, index: number, value: string | null) => {
const entry = getProductRequirementEntries(requirementId)[index]
if (!entry) return
entry.typeProductId = value || entry.typeProductId || null
}
// ---------------------------------------------------------------------------
// Skeleton initialization
// ---------------------------------------------------------------------------
const initializeSkeletonRequirementSelections = async () => {
skeletonEditor.loading = true
try {
resetSkeletonRequirementSelections()
const type = machineType.value as AnyRecord
if (!type) return
try {
await loadProducts()
} catch (error) {
console.error('Erreur lors du chargement des produits pour le squelette:', error)
}
;((type.componentRequirements as AnyRecord[]) || []).forEach((requirement) => {
const existing = flattenedComponents.value.filter(
(c) => c.typeMachineComponentRequirementId === requirement.id,
)
const entries = existing.map((c) => createComponentSelectionEntry(requirement, c))
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
while (entries.length < min) entries.push(createComponentSelectionEntry(requirement))
if (entries.length) componentRequirementSelections[requirement.id as string] = entries
})
const allPieces = collectPiecesForSkeleton()
;((type.pieceRequirements as AnyRecord[]) || []).forEach((requirement) => {
const existing = allPieces.filter(
(p) => p.typeMachinePieceRequirementId === requirement.id,
)
const entries = existing.map((p) => createPieceSelectionEntry(requirement, p))
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
while (entries.length < min) entries.push(createPieceSelectionEntry(requirement))
if (entries.length) pieceRequirementSelections[requirement.id as string] = entries
})
const existingProductLinks = Array.isArray(machineProductLinks.value)
? machineProductLinks.value
: Array.isArray(machine.value?.productLinks)
? (machine.value.productLinks as AnyRecord[])
: []
;((type.productRequirements as AnyRecord[]) || []).forEach((requirement) => {
const matches = existingProductLinks.filter((link) => {
const reqId = resolveIdentifier(link?.typeMachineProductRequirementId, link?.requirementId)
return reqId === requirement.id
})
const entries = matches.map((link) => createProductSelectionEntry(requirement, link))
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
while (entries.length < min) entries.push(createProductSelectionEntry(requirement))
if (entries.length) productRequirementSelections[requirement.id as string] = entries
})
} finally {
skeletonEditor.loading = false
}
}
// ---------------------------------------------------------------------------
// Editor open/close
// ---------------------------------------------------------------------------
const openSkeletonEditor = async () => {
if (skeletonEditor.open) return
skeletonEditor.open = true
await initializeSkeletonRequirementSelections()
}
const closeSkeletonEditor = () => {
if (!skeletonEditor.open) return
if (skeletonEditor.submitting) return
skeletonEditor.open = false
skeletonEditor.loading = false
skeletonEditor.submitting = false
resetSkeletonRequirementSelections()
}
const changeMachineView = async (view: 'details' | 'skeleton') => {
if (view === activeMachineView.value) return
if (view === 'skeleton') {
if (!machineHasSkeletonRequirements.value) {
toast.showInfo('Aucun squelette configuré pour cette machine.')
return
}
activeMachineView.value = 'skeleton'
if (!skeletonEditor.open) {
try {
await openSkeletonEditor()
} catch (error) {
console.error("Impossible d'ouvrir l'éditeur de squelette:", error)
toast.showError('Impossible de charger les éléments du squelette.')
activeMachineView.value = 'details'
}
}
return
}
closeSkeletonEditor()
activeMachineView.value = 'details'
}
// ---------------------------------------------------------------------------
// Validation & save
// ---------------------------------------------------------------------------
const computeSkeletonProductUsage = (type: AnyRecord): Map<string, number> => {
const usage = new Map<string, number>()
const increment = (typeProductId: string | null) => {
if (!typeProductId) return
usage.set(typeProductId, (usage.get(typeProductId) ?? 0) + 1)
}
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
getComponentRequirementEntries(requirement.id as string).forEach((entry) => {
if (!entry?.composantId) return
const component = findComponentById(components.value, entry.composantId as string)
const typeProductId =
((component?.product as AnyRecord)?.typeProductId as string) ||
(((component?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
null
increment(typeProductId)
})
}
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
getPieceRequirementEntries(requirement.id as string).forEach((entry) => {
if (!entry?.pieceId) return
const piece = findPieceById(entry.pieceId as string)
const typeProductId =
((piece?.product as AnyRecord)?.typeProductId as string) ||
(((piece?.product as AnyRecord)?.typeProduct as AnyRecord)?.id as string) ||
null
increment(typeProductId)
})
}
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
getProductRequirementEntries(requirement.id as string).forEach((entry) => {
if (!entry?.productId) return
const product = findProductById(entry.productId as string)
const typeProductId =
((product?.typeProductId as string) ||
((product?.typeProduct as AnyRecord)?.id as string) ||
(entry?.typeProductId as string) ||
(requirement?.typeProductId as string) ||
((requirement?.typeProduct as AnyRecord)?.id as string) ||
null)
increment(typeProductId)
})
}
return usage
}
const validateSkeletonSelections = (type: AnyRecord) => {
const errors: string[] = []
const componentLinksPayload: AnyRecord[] = []
const pieceLinksPayload: AnyRecord[] = []
const productLinksPayload: AnyRecord[] = []
for (const requirement of (type.componentRequirements as AnyRecord[]) || []) {
const entries = getComponentRequirementEntries(requirement.id as string)
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
const max = (requirement.maxCount as number | null) ?? null
if (entries.length < min) {
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite au moins ${min} élément(s).`)
}
if (max !== null && entries.length > max) {
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" ne peut dépasser ${max} élément(s).`)
}
entries.forEach((entry) => {
const resolvedTypeId = (entry.typeComposantId || requirement.typeComposantId || null) as string | null
if (!resolvedTypeId) {
errors.push(`Le groupe "${requirement.label || (requirement.typeComposant as AnyRecord)?.name || 'Composants'}" nécessite un type de composant.`)
return
}
const payload: AnyRecord = { requirementId: requirement.id, typeComposantId: resolvedTypeId }
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
if (entry.composantId) payload.composantId = entry.composantId
const overrides = sanitizeDefinitionOverrides(entry.definition)
if (overrides) payload.overrides = overrides
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
componentLinksPayload.push(payload)
})
}
for (const requirement of (type.pieceRequirements as AnyRecord[]) || []) {
const entries = getPieceRequirementEntries(requirement.id as string)
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
const max = (requirement.maxCount as number | null) ?? null
if (entries.length < min) {
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite au moins ${min} élément(s).`)
}
if (max !== null && entries.length > max) {
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" ne peut dépasser ${max} élément(s).`)
}
entries.forEach((entry) => {
const resolvedTypeId = (entry.typePieceId || requirement.typePieceId || null) as string | null
if (!resolvedTypeId) {
errors.push(`Le groupe "${requirement.label || (requirement.typePiece as AnyRecord)?.name || 'Pièces'}" nécessite un type de pièce.`)
return
}
const payload: AnyRecord = { requirementId: requirement.id, typePieceId: resolvedTypeId }
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
if (entry.pieceId) payload.pieceId = entry.pieceId
if (entry.composantId) payload.composantId = entry.composantId
const overrides = sanitizeDefinitionOverrides(entry.definition)
if (overrides) payload.overrides = overrides
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
pieceLinksPayload.push(payload)
})
}
const productUsage = computeSkeletonProductUsage(type)
for (const requirement of (type.productRequirements as AnyRecord[]) || []) {
const entries = getProductRequirementEntries(requirement.id as string)
const max = (requirement.maxCount as number | null) ?? null
if (max !== null && entries.length > max) {
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s) directe(s).`)
}
const typeProductId = (requirement.typeProductId as string) || ((requirement.typeProduct as AnyRecord)?.id as string) || null
const count = typeProductId ? productUsage.get(typeProductId) ?? 0 : 0
const min = (requirement.minCount as number) ?? (requirement.required ? 1 : 0)
if (count < min) {
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" nécessite au moins ${min} sélection(s).`)
}
if (max !== null && count > max) {
errors.push(`Le groupe "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}" ne peut dépasser ${max} sélection(s).`)
}
entries.forEach((entry) => {
if (!entry.productId) {
errors.push(`Sélectionner un produit pour "${requirement.label || (requirement.typeProduct as AnyRecord)?.name || 'Produits'}".`)
return
}
const product = findProductById(entry.productId as string)
if (!product) {
errors.push(`Le produit sélectionné est introuvable (ID: ${entry.productId}).`)
return
}
const productTypeId =
(product.typeProductId as string) ||
((product.typeProduct as AnyRecord)?.id as string) ||
(entry.typeProductId as string) ||
null
if (typeProductId && productTypeId && productTypeId !== typeProductId) {
errors.push(`Le produit "${product.name || product.reference || product.id}" n'appartient pas à la catégorie attendue.`)
return
}
const payload: AnyRecord = { requirementId: requirement.id, productId: entry.productId }
if (entry.linkId) { payload.id = entry.linkId; payload.linkId = entry.linkId }
if (entry.typeProductId) payload.typeProductId = entry.typeProductId
Object.assign(payload, extractParentLinkIdentifiers(requirement), extractParentLinkIdentifiers(entry))
productLinksPayload.push(payload)
})
}
if (errors.length > 0) return { valid: false as const, error: errors[0] }
return {
valid: true as const,
componentLinks: componentLinksPayload,
pieceLinks: pieceLinksPayload,
productLinks: productLinksPayload,
}
}
// ---------------------------------------------------------------------------
// Apply reconfiguration result
// ---------------------------------------------------------------------------
const applySkeletonReconfigurationResult = async (data: AnyRecord) => {
if (!data) return
const updatedMachine = (data.machine as AnyRecord) || data
if (updatedMachine) {
machine.value = {
...machine.value,
...updatedMachine,
documents: (updatedMachine.documents as AnyRecord[]) || (machine.value?.documents as AnyRecord[]) || [],
}
initMachineFields()
machineDocumentsLoaded.value = !!((machine.value!.documents as AnyRecord[])?.length)
}
const linksApplied = applyMachineLinks(data) || applyMachineLinks(updatedMachine)
if (linksApplied) {
if (machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
machine.value.productLinks = machineProductLinks.value
}
collapseAllComponents()
return
}
const newComponents = (data.components ?? updatedMachine?.components ?? null) as AnyRecord[] | null
if (Array.isArray(newComponents)) {
components.value = transformComponentCustomFields(newComponents)
collapseAllComponents()
}
const newPieces = (data.pieces ?? updatedMachine?.pieces ?? null) as AnyRecord[] | null
if (Array.isArray(newPieces)) {
pieces.value = transformCustomFields(newPieces)
}
const prodLinks =
resolveLinkArray(data, ['productLinks', 'machineProductLinks']) ??
resolveLinkArray(updatedMachine, ['productLinks', 'machineProductLinks'])
if (Array.isArray(prodLinks)) {
machineProductLinks.value = prodLinks as AnyRecord[]
if (machine.value) machine.value.productLinks = prodLinks
}
}
// ---------------------------------------------------------------------------
// Save
// ---------------------------------------------------------------------------
const saveSkeletonConfiguration = async () => {
if (!machine.value?.id) return
const type = machineType.value as AnyRecord
let payload: AnyRecord = { componentLinks: [], pieceLinks: [], productLinks: [] }
if (type && machineHasSkeletonRequirements.value) {
const validation = validateSkeletonSelections(type)
if (!validation.valid) {
toast.showError((validation as AnyRecord).error as string)
return
}
payload = {
componentLinks: (validation as AnyRecord).componentLinks,
pieceLinks: (validation as AnyRecord).pieceLinks,
productLinks: (validation as AnyRecord).productLinks,
}
}
skeletonEditor.submitting = true
try {
const result = await reconfigureMachineSkeleton(machine.value.id as string, payload)
if ((result as AnyRecord).success) {
await applySkeletonReconfigurationResult((result as AnyRecord).data as AnyRecord)
await changeMachineView('details')
} else if ((result as AnyRecord).error) {
toast.showError((result as AnyRecord).error as string)
}
} catch (error) {
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error)
toast.showError('Erreur lors de la mise à jour des éléments du squelette')
} finally {
skeletonEditor.submitting = false
skeletonEditor.loading = false
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
// View state
activeMachineView,
isDetailsView,
isSkeletonView,
// Editor state
skeletonEditor,
componentRequirementSelections,
pieceRequirementSelections,
productRequirementSelections,
// Entry getters
getComponentRequirementEntries,
getPieceRequirementEntries,
getProductRequirementEntries,
// Label resolvers
resolveComponentRequirementTypeLabel,
resolvePieceRequirementTypeLabel,
resolveProductRequirementTypeLabel,
getProductOptionsForRequirement,
// Selection CRUD
addComponentSelectionEntry,
removeComponentSelectionEntry,
setComponentRequirementType,
setComponentRequirementConstructeur,
addPieceSelectionEntry,
removePieceSelectionEntry,
setPieceRequirementType,
setPieceRequirementConstructeur,
addProductSelectionEntry,
removeProductSelectionEntry,
setProductRequirementProduct,
setProductRequirementType,
// Editor lifecycle
openSkeletonEditor,
closeSkeletonEditor,
changeMachineView,
initializeSkeletonRequirementSelections,
// Validation & save
validateSkeletonSelections,
saveSkeletonConfiguration,
}
}

View File

@@ -1,186 +0,0 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi, type ApiResponse } from './useApi'
import { extractRelationId } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface MachineTypeRequirement {
id?: string
label?: string
minCount?: number
maxCount?: number
required?: boolean
[key: string]: unknown
}
export interface MachineType {
id: string
name: string
componentRequirements: MachineTypeRequirement[]
pieceRequirements: MachineTypeRequirement[]
productRequirements: MachineTypeRequirement[]
[key: string]: unknown
}
const machineTypes = ref<MachineType[]>([])
const loading = ref(false)
const loaded = ref(false)
const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
if (!Array.isArray(value)) {
return []
}
return value.map((entry: Record<string, unknown>, _index: number) => {
if (!entry || typeof entry !== 'object') {
return entry
}
const normalized = { ...entry }
const relationField = relationKey.replace('Id', '')
const relationValue = normalized[relationField]
if (relationKey && !normalized[relationKey]) {
const relationId = extractRelationId(relationValue)
if (relationId) {
normalized[relationKey] = relationId
}
}
return normalized as MachineTypeRequirement
})
}
const normalizeMachineType = (type: Record<string, unknown>): MachineType | null => {
if (!type || typeof type !== 'object') {
return null
}
return {
...type,
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
} as MachineType
}
export function useMachineTypesApi() {
const { showSuccess } = useToast()
const { get, post, put, delete: del } = useApi()
const loadMachineTypes = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true
try {
const result = await get('/type_machines')
if (result.success) {
const items = extractCollection(result.data)
machineTypes.value = items
.map((item) => normalizeMachineType(item as Record<string, unknown>))
.filter((item): item is MachineType => item !== null)
loaded.value = true
}
} catch (error) {
console.error('Erreur lors du chargement des types de machines:', error)
} finally {
loading.value = false
}
}
const createMachineType = async (typeData: Partial<MachineType>): Promise<ApiResponse> => {
loading.value = true
try {
const result = await post('/type_machines', typeData)
if (result.success) {
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
if (normalized) machineTypes.value.push(normalized)
showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors de la création du type de machine:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const updateMachineType = async (id: string, typeData: Partial<MachineType>): Promise<ApiResponse> => {
loading.value = true
try {
const result = await put(`/type_machines/${id}`, typeData)
if (result.success) {
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
const index = machineTypes.value.findIndex((type) => type.id === id)
if (index !== -1 && normalized) {
machineTypes.value[index] = normalized
}
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors de la mise à jour du type de machine:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const deleteMachineType = async (id: string): Promise<ApiResponse> => {
loading.value = true
try {
const result = await del(`/type_machines/${id}`)
if (result.success) {
const deletedType = machineTypes.value.find((type) => type.id === id)
machineTypes.value = machineTypes.value.filter((type) => type.id !== id)
showSuccess(`Type de machine "${deletedType?.name || 'inconnu'}" supprimé avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors de la suppression du type de machine:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const getMachineTypeById = async (id: string, forceRefresh = false): Promise<ApiResponse> => {
// D'abord chercher dans le cache local (sauf si forceRefresh)
if (!forceRefresh) {
const localType = machineTypes.value.find((type) => type.id === id)
if (localType) {
return { success: true, data: localType }
}
}
// Récupérer depuis l'API
try {
const result = await get(`/type_machines/${id}`)
if (result.success) {
const normalized = normalizeMachineType(result.data as Record<string, unknown>)
// Mettre à jour le cache local
const index = machineTypes.value.findIndex((type) => type.id === id)
if (index !== -1 && normalized) {
machineTypes.value[index] = normalized
} else if (normalized) {
machineTypes.value.push(normalized)
}
return { success: true, data: normalized }
}
return result
} catch (error) {
console.error('Erreur lors de la récupération du type de machine:', error)
return { success: false, error: (error as Error).message }
}
}
const getMachineTypes = (): MachineType[] => machineTypes.value
const isLoading = (): boolean => loading.value
return {
machineTypes,
loading,
loadMachineTypes,
createMachineType,
updateMachineType,
deleteMachineType,
getMachineTypeById,
getMachineTypes,
isLoading,
}
}

View File

@@ -9,7 +9,6 @@ export interface Machine {
id: string id: string
name?: string name?: string
siteId?: string | null siteId?: string | null
typeMachineId?: string | null
componentLinks?: unknown[] componentLinks?: unknown[]
pieceLinks?: unknown[] pieceLinks?: unknown[]
[key: string]: unknown [key: string]: unknown
@@ -53,13 +52,6 @@ const normalizeMachineResponse = (payload: unknown): Machine | null => {
} }
} }
if (!normalized.typeMachineId) {
const typeMachineId = extractRelationId(container.typeMachine)
if (typeMachineId) {
normalized.typeMachineId = typeMachineId
}
}
const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ?? const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ??
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ?? resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
[] []
@@ -121,15 +113,6 @@ export function useMachines() {
} }
} }
const createMachineFromType = async (machineData: Partial<Machine>, typeMachine: { id: string }): Promise<ApiResponse> => {
const machineWithStructure = {
...machineData,
typeMachineId: typeMachine.id,
}
return await createMachine(machineWithStructure)
}
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 {
@@ -157,14 +140,14 @@ export function useMachines() {
} }
} }
const reconfigureSkeleton = async (machineId: string, payload: unknown): Promise<ApiResponse> => { const updateStructure = async (machineId: string, payload: unknown): Promise<ApiResponse> => {
if (!machineId) { if (!machineId) {
return { success: false, error: 'Identifiant de machine manquant' } return { success: false, error: 'Identifiant de machine manquant' }
} }
loading.value = true loading.value = true
try { try {
const result = await patch(`/machines/${machineId}/skeleton`, payload) const result = await patch(`/machines/${machineId}/structure`, payload)
if (result.success) { if (result.success) {
const index = machines.value.findIndex((machine) => machine.id === machineId) const index = machines.value.findIndex((machine) => machine.id === machineId)
if (index !== -1) { if (index !== -1) {
@@ -180,7 +163,29 @@ export function useMachines() {
} }
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la reconfiguration du squelette de la machine:', error) console.error('Erreur lors de la mise à jour de la structure de la machine:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string }): Promise<ApiResponse> => {
loading.value = true
try {
const result = await post(`/machines/${sourceId}/clone`, data)
if (result.success) {
const clonedMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
null
if (clonedMachine) {
machines.value.push(clonedMachine)
}
showSuccess(`Machine "${clonedMachine?.name || data.name}" clonée avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors du clonage de la machine:', error)
return { success: false, error: (error as Error).message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
@@ -241,10 +246,6 @@ export function useMachines() {
return machines.value.filter((machine) => machine.siteId === siteId) return machines.value.filter((machine) => machine.siteId === siteId)
} }
const getMachinesByType = (typeMachineId: string): Machine[] => {
return machines.value.filter((machine) => machine.typeMachineId === typeMachineId)
}
const getMachines = (): Machine[] => machines.value const getMachines = (): Machine[] => machines.value
const isLoading = (): boolean => loading.value const isLoading = (): boolean => loading.value
@@ -253,13 +254,12 @@ export function useMachines() {
loading, loading,
loadMachines, loadMachines,
createMachine, createMachine,
createMachineFromType,
updateMachine: updateMachineData, updateMachine: updateMachineData,
reconfigureSkeleton, updateStructure,
cloneMachine,
deleteMachine, deleteMachine,
getMachineById, getMachineById,
getMachinesBySite, getMachinesBySite,
getMachinesByType,
getMachines, getMachines,
isLoading, isLoading,
addMissingCustomFields, addMissingCustomFields,

View File

@@ -0,0 +1,41 @@
import { computed } from 'vue'
import { useProfileSession } from './useProfileSession'
const ROLE_HIERARCHY: Record<string, string[]> = {
ROLE_ADMIN: ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
ROLE_GESTIONNAIRE: ['ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
ROLE_VIEWER: ['ROLE_VIEWER', 'ROLE_USER'],
ROLE_USER: ['ROLE_USER'],
}
export function usePermissions() {
const { activeProfile } = useProfileSession()
const effectiveRoles = computed<string[]>(() => {
const roles = (activeProfile.value?.roles as string[] | undefined) ?? ['ROLE_USER']
const all = new Set<string>()
for (const role of roles) {
const inherited = ROLE_HIERARCHY[role] ?? [role]
for (const r of inherited) {
all.add(r)
}
}
return [...all]
})
const isGranted = (role: string): boolean => {
return effectiveRoles.value.includes(role)
}
const isAdmin = computed(() => isGranted('ROLE_ADMIN'))
const canEdit = computed(() => isGranted('ROLE_GESTIONNAIRE'))
const canView = computed(() => isGranted('ROLE_VIEWER'))
return {
isAdmin,
canEdit,
canView,
isGranted,
effectiveRoles,
}
}

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