29 Commits

Author SHA1 Message Date
Matthieu
79d2df8bc6 perf(composables) : add smart cache to usePieces and useComposants
Align with useProducts pattern: loaded flag, cache-first return,
loading guard, and clearCache helper to avoid redundant API calls.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 19:53:56 +01:00
Matthieu
202b964b24 chore(branding): update navbar logo and app name 2026-01-25 22:31:40 +01:00
Matthieu
a1d15c23a4 feat(history): add audit history views for products, pieces and components 2026-01-25 21:20:14 +01:00
Matthieu
a7101c7e77 feat(model-types): add related-items modal and guard category edits 2026-01-25 20:29:28 +01:00
138 changed files with 16316 additions and 13038 deletions

View File

@@ -1,696 +1,22 @@
<template> <template>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100"> <div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<!-- Navbar --> <LayoutAppNavbar
<div class="navbar bg-base-100 shadow-lg"> @open-settings="displaySettingsOpen = true"
<div class="navbar-start"> @logout="handleLogout"
<div class="dropdown"> />
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
>
<li class="pt-1 pb-2 lg:hidden">
<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"
@click="openDisplaySettings"
>
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
Paramètres d'affichage
</button>
</li>
<li>
<NuxtLink
to="/"
class="rounded-md px-2 py-1 transition-colors"
:class="
isActive('/')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Vue d'ensemble
</NuxtLink>
</li>
<li>
<NuxtLink
to="/machines"
class="rounded-md px-2 py-1 transition-colors"
:class="
isActive('/machines')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Parc Machines
</NuxtLink>
</li>
<li>
<NuxtLink
to="/machine-skeleton"
class="rounded-md px-2 py-1 transition-colors"
:class="
isActive('/machine-skeleton')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Squelettes de machine
</NuxtLink>
</li>
<li class="mt-1 border-t border-base-200 pt-2">
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
:class="
isActive('/piece-category') || isActive('/pieces-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('pieces-mobile')"
@keydown.enter.prevent="toggleDropdown('pieces-mobile')"
@keydown.space.prevent="toggleDropdown('pieces-mobile')"
:aria-expanded="openDropdown === 'pieces-mobile'"
>
<span>Pièces</span>
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'pieces-mobile' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-mobile">
<ul
v-if="openDropdown === 'pieces-mobile'"
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
>
<li>
<NuxtLink
to="/pieces-catalog"
class="rounded-md px-2 py-1 transition-colors block"
:class="
isActive('/pieces-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue des pièces
</NuxtLink>
</li>
<li>
<NuxtLink
to="/piece-category"
class="rounded-md px-2 py-1 transition-colors block"
:class="
isActive('/piece-category')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catégorie de pièce
</NuxtLink>
</li>
</ul>
</Transition>
</li>
<li class="mt-1 border-t border-base-200 pt-2">
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
:class="
isActive('/product-category') || isActive('/product-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('products-mobile')"
@keydown.enter.prevent="toggleDropdown('products-mobile')"
@keydown.space.prevent="toggleDropdown('products-mobile')"
:aria-expanded="openDropdown === 'products-mobile'"
>
<span>Produits</span>
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'products-mobile' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-mobile">
<ul
v-if="openDropdown === 'products-mobile'"
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
>
<li>
<NuxtLink
to="/product-catalog"
class="rounded-md px-2 py-1 transition-colors block"
:class="
isActive('/product-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue des produits
</NuxtLink>
</li>
<li>
<NuxtLink
to="/product-category"
class="rounded-md px-2 py-1 transition-colors block"
:class="
isActive('/product-category')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catégorie de produit
</NuxtLink>
</li>
</ul>
</Transition>
</li>
<li class="mt-1 border-t border-base-200 pt-2">
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
:class="
isActive('/component-category') || isActive('/component-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('component-mobile')"
@keydown.enter.prevent="toggleDropdown('component-mobile')"
@keydown.space.prevent="toggleDropdown('component-mobile')"
:aria-expanded="openDropdown === 'component-mobile'"
>
<span>Composant</span>
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'component-mobile' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-mobile">
<ul
v-if="openDropdown === 'component-mobile'"
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
>
<li>
<NuxtLink
to="/component-catalog"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/component-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue des composants
</NuxtLink>
</li>
<li>
<NuxtLink
to="/component-category"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/component-category')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catégorie de composant
</NuxtLink>
</li>
</ul>
</Transition>
</li>
<li class="mt-1 border-t border-base-200 pt-2">
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
:class="
isActive('/sites') ||
isActive('/documents') ||
isActive('/constructeurs')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('resources-mobile')"
@keydown.enter.prevent="toggleDropdown('resources-mobile')"
@keydown.space.prevent="toggleDropdown('resources-mobile')"
:aria-expanded="openDropdown === 'resources-mobile'"
>
<span>Ressources liées</span>
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'resources-mobile' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-mobile">
<ul
v-if="openDropdown === 'resources-mobile'"
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
>
<li>
<NuxtLink
to="/sites"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/sites')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Sites
</NuxtLink>
</li>
<li>
<NuxtLink
to="/documents"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/documents')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Documents
</NuxtLink>
</li>
<li>
<NuxtLink
to="/constructeurs"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/constructeurs')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Fournisseurs
</NuxtLink>
</li>
</ul>
</Transition>
</li>
</ul>
</div>
<div class="flex items-center space-x-3">
<div class="avatar placeholder">
<div
class="bg-primary text-primary-content rounded-lg w-10 grid place-items-center"
>
<IconLucideBoxes class="w-6 h-6" aria-hidden="true" />
</div>
</div>
<NuxtLink to="/" class="btn btn-ghost text-xl">
Inventaire Pro
</NuxtLink>
</div>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li>
<NuxtLink
to="/"
class="transition-colors px-3 py-2 rounded-md"
:class="
isActive('/')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Vue d'ensemble
</NuxtLink>
</li>
<li>
<NuxtLink
to="/machines"
class="transition-colors px-3 py-2 rounded-md"
:class="
isActive('/machines')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Parc Machines
</NuxtLink>
</li>
<li>
<NuxtLink
to="/machine-skeleton"
class="transition-colors px-3 py-2 rounded-md"
:class="
isActive('/machine-skeleton')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Squelettes de machine
</NuxtLink>
</li>
<li
class="relative"
@mouseenter="setDropdown('pieces-desktop')"
@mouseleave="scheduleDropdownClose('pieces-desktop')"
@focusin="setDropdown('pieces-desktop')"
@focusout="scheduleDropdownClose('pieces-desktop')"
>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
:class="
isActive('/piece-category') || isActive('/pieces-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('pieces-desktop')"
@keydown.enter.prevent="toggleDropdown('pieces-desktop')"
@keydown.space.prevent="toggleDropdown('pieces-desktop')"
:aria-expanded="openDropdown === 'pieces-desktop'"
>
Pièces
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'pieces-desktop' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-desktop">
<ul
v-if="openDropdown === 'pieces-desktop'"
class="absolute left-0 top-full mt-2 w-60 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
>
<li>
<NuxtLink
to="/piece-category"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/piece-category')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catégorie de pièce
</NuxtLink>
</li>
<li>
<NuxtLink
to="/pieces-catalog"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/pieces-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue des pièces
</NuxtLink>
</li>
</ul>
</Transition>
</li>
<li
class="relative"
@mouseenter="setDropdown('products-desktop')"
@mouseleave="scheduleDropdownClose('products-desktop')"
@focusin="setDropdown('products-desktop')"
@focusout="scheduleDropdownClose('products-desktop')"
>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
:class="
isActive('/product-category') || isActive('/product-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('products-desktop')"
@keydown.enter.prevent="toggleDropdown('products-desktop')"
@keydown.space.prevent="toggleDropdown('products-desktop')"
:aria-expanded="openDropdown === 'products-desktop'"
>
Produits
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'products-desktop' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-desktop">
<ul
v-if="openDropdown === 'products-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"
>
<li>
<NuxtLink
to="/product-category"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/product-category')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catégorie de produit
</NuxtLink>
</li>
<li>
<NuxtLink
to="/product-catalog"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/product-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue des produits
</NuxtLink>
</li>
</ul>
</Transition>
</li>
<li
class="relative"
@mouseenter="setDropdown('component-desktop')"
@mouseleave="scheduleDropdownClose('component-desktop')"
@focusin="setDropdown('component-desktop')"
@focusout="scheduleDropdownClose('component-desktop')"
>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
:class="
isActive('/component-category') ||
isActive('/component-catalog')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('component-desktop')"
@keydown.enter.prevent="toggleDropdown('component-desktop')"
@keydown.space.prevent="toggleDropdown('component-desktop')"
:aria-expanded="openDropdown === 'component-desktop'"
>
Composant
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'component-desktop' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-desktop">
<ul
v-if="openDropdown === 'component-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"
>
<li>
<NuxtLink
to="/component-category"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/component-category')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catégorie de composant
</NuxtLink>
</li>
<li>
<NuxtLink
to="/component-catalog"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/component-catalog')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Catalogue des composants
</NuxtLink>
</li>
</ul>
</Transition>
</li>
<li
class="relative"
@mouseenter="setDropdown('resources-desktop')"
@mouseleave="scheduleDropdownClose('resources-desktop')"
@focusin="setDropdown('resources-desktop')"
@focusout="scheduleDropdownClose('resources-desktop')"
>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
:class="
isActive('/sites') ||
isActive('/documents') ||
isActive('/constructeurs')
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
@click="toggleDropdown('resources-desktop')"
@keydown.enter.prevent="toggleDropdown('resources-desktop')"
@keydown.space.prevent="toggleDropdown('resources-desktop')"
:aria-expanded="openDropdown === 'resources-desktop'"
>
Ressources liées
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === 'resources-desktop' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-desktop">
<ul
v-if="openDropdown === 'resources-desktop'"
class="absolute left-0 top-full mt-2 w-60 rounded-lg border border-base-200 bg-base-100 p-2 shadow-lg z-50"
>
<li>
<NuxtLink
to="/sites"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/sites')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Sites
</NuxtLink>
</li>
<li>
<NuxtLink
to="/documents"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/documents')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Documents
</NuxtLink>
</li>
<li>
<NuxtLink
to="/constructeurs"
class="block rounded-md px-2 py-1 transition-colors"
:class="
isActive('/constructeurs')
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
"
>
Fournisseurs
</NuxtLink>
</li>
</ul>
</Transition>
</li>
</ul>
</div>
<div class="navbar-end">
<div class="flex items-center gap-2">
<!-- Bouton paramètres d'affichage -->
<button
class="btn btn-ghost btn-circle hidden lg:inline-flex"
title="Paramètres d'affichage"
@click="openDisplaySettings"
>
<IconLucideSettings class="w-5 h-5" aria-hidden="true" />
</button>
<ClientOnly>
<div v-if="activeProfile" class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="btn btn-ghost btn-circle avatar placeholder"
>
<div
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
>
<span
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight"
>
{{ activeProfileInitials }}
</span>
</div>
</div>
<ul
tabindex="0"
class="menu dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-64"
>
<li class="px-2 py-1 text-sm text-base-content/70">
Connecté en tant que<br />
<span class="font-semibold text-base-content">{{
activeProfileLabel
}}</span>
</li>
<li>
<NuxtLink to="/profiles/manage" class="justify-between">
Gestion des profils
<IconLucideChevronRight
class="w-4 h-4"
aria-hidden="true"
/>
</NuxtLink>
</li>
<li>
<button
type="button"
class="text-error justify-between"
@click="handleLogout"
>
Déconnexion
<IconLucideLogOut class="w-4 h-4" aria-hidden="true" />
</button>
</li>
</ul>
</div>
</ClientOnly>
</div>
</div>
</div>
<!-- Page Content -->
<NuxtPage /> <NuxtPage />
<!-- Toast Notifications -->
<ToastContainer /> <ToastContainer />
<!-- Paramètres d'affichage --> <CommonConfirmModal />
<DisplaySettings <DisplaySettings
:is-open="displaySettingsOpen" :is-open="displaySettingsOpen"
@close="closeDisplaySettings" @close="displaySettingsOpen = false"
@update-settings="handleSettingsUpdate" @update-settings="handleSettingsUpdate"
/> />
<!-- Footer -->
<footer class="footer p-4 bg-neutral text-neutral-content"> <footer class="footer p-4 bg-neutral text-neutral-content">
<div class="items-center grid-flow-col"> <div class="items-center grid-flow-col">
<p>@Malio 2025 · v{{ appVersion }}</p> <p>@Malio 2025 · v{{ appVersion }}</p>
@@ -699,161 +25,26 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue"; import { ref, computed, onMounted } from 'vue'
import { useRoute, navigateTo, useRuntimeConfig } from "#imports"; import { navigateTo, useRuntimeConfig } from '#imports'
import { useProfileSession } from "~/composables/useProfileSession"; import { useProfileSession } from '~/composables/useProfileSession'
import IconLucideMenu from "~icons/lucide/menu";
import IconLucideSettings from "~icons/lucide/settings";
import IconLucideBoxes from "~icons/lucide/boxes";
import IconLucidePlus from "~icons/lucide/plus";
import IconLucideCpu from "~icons/lucide/cpu";
import IconLucideFilePlus from "~icons/lucide/file-plus";
import IconLucideMapPin from "~icons/lucide/map-pin";
import IconLucideChevronRight from "~icons/lucide/chevron-right";
import IconLucideLogOut from "~icons/lucide/log-out";
// État du modal des paramètres d'affichage const displaySettingsOpen = ref(false)
const displaySettingsOpen = ref(false); const { ensureSession, logout } = useProfileSession()
const { activeProfile, ensureSession, logout } = useProfileSession(); const runtimeConfig = useRuntimeConfig()
const runtimeConfig = useRuntimeConfig(); const appVersion = computed(() => (runtimeConfig.public?.appVersion as string) ?? '0.1.0')
const appVersion = computed(() => runtimeConfig.public?.appVersion ?? "0.1.0");
// Route active pour souligner l'onglet sélectionné dans la navbar const handleSettingsUpdate = (_settings: unknown) => {
const route = useRoute(); // Placeholder for future persistence
const isActive = (path) => { }
if (path === "/") {
return route.path === "/";
}
return route.path.startsWith(path);
};
// Ouvrir les paramètres d'affichage
const openDisplaySettings = () => {
displaySettingsOpen.value = true;
};
// Fermer les paramètres d'affichage
const closeDisplaySettings = () => {
displaySettingsOpen.value = false;
};
// Gérer les mises à jour des paramètres
const handleSettingsUpdate = (settings) => {
console.log("Paramètres d'affichage mis à jour:", settings);
};
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout()
await navigateTo("/profiles"); await navigateTo('/profiles')
}; }
const openDropdown = ref(null);
let dropdownCloseTimer = null;
const setDropdown = (name) => {
if (dropdownCloseTimer) {
clearTimeout(dropdownCloseTimer);
dropdownCloseTimer = null;
}
if (openDropdown.value !== name) {
openDropdown.value = name;
}
};
const scheduleDropdownClose = (name) => {
if (dropdownCloseTimer) {
clearTimeout(dropdownCloseTimer);
}
dropdownCloseTimer = setTimeout(() => {
if (openDropdown.value === name) {
openDropdown.value = null;
}
dropdownCloseTimer = null;
}, 200);
};
const closeDropdownNow = () => {
if (dropdownCloseTimer) {
clearTimeout(dropdownCloseTimer);
dropdownCloseTimer = null;
}
openDropdown.value = null;
};
const toggleDropdown = (name) => {
if (openDropdown.value === name) {
closeDropdownNow();
return;
}
setDropdown(name);
};
watch(
() => route.fullPath,
() => {
closeDropdownNow();
},
);
const activeProfileLabel = computed(() => {
if (!activeProfile.value) {
return "Profil inconnu";
}
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`;
});
const activeProfileInitials = computed(() => {
if (!activeProfile.value) {
return "??";
}
const { firstName = "", lastName = "" } = activeProfile.value;
return (
`${firstName.charAt(0) || ""}${lastName.charAt(0) || ""}`.toUpperCase() ||
"??"
);
});
onMounted(async () => { onMounted(async () => {
await ensureSession(); await ensureSession()
}); })
onUnmounted(() => {
if (dropdownCloseTimer) {
clearTimeout(dropdownCloseTimer);
dropdownCloseTimer = null;
}
});
</script> </script>
<style scoped>
.nav-dropdown-desktop-enter-active,
.nav-dropdown-desktop-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.nav-dropdown-desktop-enter-from,
.nav-dropdown-desktop-leave-to {
opacity: 0;
transform: translateY(0.25rem);
}
.nav-dropdown-desktop-enter-to,
.nav-dropdown-desktop-leave-from {
opacity: 1;
transform: translateY(0);
}
.nav-dropdown-mobile-enter-active,
.nav-dropdown-mobile-leave-active {
transition: max-height 0.2s ease, opacity 0.2s ease;
}
.nav-dropdown-mobile-enter-from,
.nav-dropdown-mobile-leave-to {
max-height: 0;
opacity: 0;
}
.nav-dropdown-mobile-enter-to,
.nav-dropdown-mobile-leave-from {
max-height: 12rem;
opacity: 1;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -6,26 +6,31 @@
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */ prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
color-scheme: light; /* color of browser-provided UI */ color-scheme: light; /* color of browser-provided UI */
--color-base-100: oklch(98% 0.02 240); /* #FBFAFA — gris clair */
--color-base-200: oklch(95% 0.03 240); --color-base-100: oklch(98% 0.003 0);
--color-base-300: oklch(92% 0.04 240); --color-base-200: oklch(94% 0.01 262);
--color-base-content: oklch(20% 0.05 240); --color-base-300: oklch(90% 0.02 262);
--color-primary: oklch(55% 0.3 240); --color-base-content: oklch(20% 0.03 262);
--color-primary-content: oklch(98% 0.01 240); /* #304998 — bleu Malio */
--color-secondary: oklch(70% 0.25 200); --color-primary: oklch(37% 0.15 262);
--color-secondary-content: oklch(98% 0.01 200); --color-primary-content: oklch(98% 0.005 262);
--color-accent: oklch(65% 0.25 160); /* #A5ACD0 — lavande */
--color-accent-content: oklch(98% 0.01 160); --color-secondary: oklch(75% 0.055 270);
--color-neutral: oklch(50% 0.05 240); --color-secondary-content: oklch(20% 0.03 270);
--color-neutral-content: oklch(98% 0.01 240); /* #ED8521 — orange */
--color-info: oklch(70% 0.2 220); --color-accent: oklch(71% 0.17 58);
--color-info-content: oklch(98% 0.01 220); --color-accent-content: oklch(98% 0.005 58);
--color-success: oklch(65% 0.25 140); /* neutral dérivé du bleu Malio */
--color-success-content: oklch(98% 0.01 140); --color-neutral: oklch(37% 0.08 262);
--color-warning: oklch(80% 0.25 80); --color-neutral-content: oklch(98% 0.005 262);
--color-warning-content: oklch(20% 0.05 80); --color-info: oklch(55% 0.12 262);
--color-error: oklch(65% 0.3 30); --color-info-content: oklch(98% 0.005 262);
--color-error-content: oklch(98% 0.01 30); --color-success: oklch(65% 0.2 145);
--color-success-content: oklch(98% 0.005 145);
--color-warning: oklch(78% 0.15 70);
--color-warning-content: oklch(20% 0.05 70);
--color-error: oklch(60% 0.25 25);
--color-error-content: oklch(98% 0.005 25);
/* border radius */ /* border radius */
--radius-selector: 1rem; --radius-selector: 1rem;
@@ -114,7 +119,7 @@
/* Focus visible pour l'accessibilité */ /* Focus visible pour l'accessibilité */
*:focus-visible { *:focus-visible {
outline: 2px solid #3b82f6; outline: 2px solid #304998;
outline-offset: 2px; outline-offset: 2px;
} }

View File

@@ -401,7 +401,7 @@
:key="piece.id" :key="piece.id"
:piece="piece" :piece="piece"
:is-edit-mode="isEditMode && !piece.skeletonOnly" :is-edit-mode="isEditMode && !piece.skeletonOnly"
@update="updatePiece" @update="updatePiece"
@edit="editPiece" @edit="editPiece"
@custom-field-update="updatePieceCustomField" @custom-field-update="updatePieceCustomField"
@@ -437,200 +437,98 @@ import { ref, watch, computed } from 'vue'
import PieceItem from './PieceItem.vue' import PieceItem from './PieceItem.vue'
import DocumentUpload from './DocumentUpload.vue' import DocumentUpload from './DocumentUpload.vue'
import ConstructeurSelect from './ConstructeurSelect.vue' import ConstructeurSelect from './ConstructeurSelect.vue'
import { useDocuments } from '~/composables/useDocuments'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right' import IconLucideChevronRight from '~icons/lucide/chevron-right'
import { useCustomFields } from '~/composables/useCustomFields' import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useToast } from '~/composables/useToast'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { import {
formatConstructeurContact as formatConstructeurContactSummary, formatConstructeurContact as formatConstructeurContactSummary,
resolveConstructeurs, resolveConstructeurs,
uniqueConstructeurIds, uniqueConstructeurIds,
} from '~/shared/constructeurUtils' } from '~/shared/constructeurUtils'
import {
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
documentIcon,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
resolveFieldKey,
resolveFieldName,
resolveFieldType,
resolveFieldOptions,
resolveFieldRequired,
resolveFieldReadOnly,
formatFieldDisplayValue,
} from '~/shared/utils/entityCustomFieldLogic'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
const props = defineProps({ const props = defineProps({
component: { component: { type: Object, required: true },
type: Object, isEditMode: { type: Boolean, default: false },
required: true collapseAll: { type: Boolean, default: true },
}, toggleToken: { type: Number, default: 0 },
isEditMode: {
type: Boolean,
default: false
},
collapseAll: {
type: Boolean,
default: true
},
toggleToken: {
type: Number,
default: 0
}
}) })
const emit = defineEmits([ const emit = defineEmits(['update', 'edit-piece', 'custom-field-update'])
'update',
'edit-piece',
'custom-field-update'
])
// --- Shared composables ---
const {
documents: componentDocuments,
selectedFiles,
uploadingDocuments,
loadingDocuments,
previewDocument,
previewVisible,
openPreview,
closePreview,
ensureDocumentsLoaded,
handleFilesAdded,
removeDocument,
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
const {
displayProduct,
displayProductName,
productInfoRows,
productDocuments,
} = useEntityProductDisplay({ entity: () => props.component })
const {
displayedCustomFields,
updateCustomField: updateComponentCustomField,
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
// --- Collapse state ---
const isCollapsed = ref(true) const isCollapsed = ref(true)
const selectedFiles = ref([])
const uploadingDocuments = ref(false) watch(
const loadingDocuments = ref(false) () => props.toggleToken,
const documentsLoaded = ref(!!(props.component.documents && props.component.documents.length)) () => {
const componentDocuments = computed(() => props.component.documents || []) isCollapsed.value = props.collapseAll
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType }) if (!isCollapsed.value) ensureDocumentsLoaded()
const previewDocument = ref(null) },
const previewVisible = ref(false) { immediate: true },
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024 )
const shouldInlinePdf = (document) => {
if (!document || !isPdfDocument(document) || !document.path) { const toggleCollapse = () => {
return false isCollapsed.value = !isCollapsed.value
} if (!isCollapsed.value) ensureDocumentsLoaded()
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
return false
}
return true
}
const appendPdfViewerParams = (src) => {
if (!src || src.startsWith('data:')) {
return src || ''
}
if (src.includes('#')) {
return `${src}&toolbar=0&navpanes=0`
}
return `${src}#toolbar=0&navpanes=0`
}
const documentPreviewSrc = (document) => {
if (!document?.path) {
return ''
}
if (isPdfDocument(document)) {
return appendPdfViewerParams(document.path)
}
return document.path
}
const documentThumbnailClass = (document) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return 'h-24 w-20'
}
return 'h-16 w-16'
} }
// --- Child components ---
const childComponents = computed(() => { const childComponents = computed(() => {
const list = props.component.subcomponents || props.component.subComponents || [] const list = props.component.subcomponents || props.component.subComponents || []
return Array.isArray(list) ? list : [] return Array.isArray(list) ? list : []
}) })
// --- Constructeurs ---
const { constructeurs } = useConstructeurs() const { constructeurs } = useConstructeurs()
const buildProductDisplay = (product) => {
if (!product || typeof product !== 'object') {
return null
}
const suppliers = Array.isArray(product.constructeurs)
? product.constructeurs
.map((constructeur) => constructeur?.name)
.filter((name) => typeof name === 'string' && name.trim().length > 0)
.join(', ')
: product.supplierLabel || null
const priceValue =
product.supplierPrice ??
product.price ??
product.priceLabel ??
product.priceDisplay ??
null
let price = null
if (priceValue !== null && priceValue !== undefined) {
const parsed = Number(priceValue)
if (!Number.isNaN(parsed)) {
price = currencyFormatter.format(parsed)
} else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
price = priceValue
}
}
return {
name:
product.name ||
product.label ||
product.reference ||
product.productName ||
null,
reference: product.reference || null,
category: product.typeProduct?.name || product.category || null,
suppliers,
price,
}
}
const displayProduct = computed(() => {
const explicit = props.component.product || null
const normalized = buildProductDisplay(explicit)
if (normalized) {
return normalized
}
const fallback = props.component.__productDisplay
if (fallback) {
return {
name: fallback.name || null,
reference: fallback.reference || null,
category: fallback.category || null,
suppliers: fallback.suppliers || null,
price: fallback.price || null,
}
}
return null
})
const displayProductName = computed(() => {
if (displayProduct.value?.name) {
return displayProduct.value.name
}
return (
props.component.product?.name ||
props.component.productName ||
props.component.productLabel ||
null
)
})
const displayProductCategory = computed(() => displayProduct.value?.category || null)
const displayProductReference = computed(() => displayProduct.value?.reference || null)
const displayProductSuppliers = computed(() => displayProduct.value?.suppliers || null)
const displayProductPrice = computed(() => displayProduct.value?.price || null)
const productInfoRows = computed(() => {
if (!displayProduct.value) {
return []
}
const rows = []
if (displayProductReference.value) {
rows.push({ label: 'Référence', value: displayProductReference.value })
}
if (displayProductPrice.value) {
rows.push({ label: 'Prix indicatif', value: displayProductPrice.value })
}
if (displayProductSuppliers.value) {
rows.push({ label: 'Fournisseur(s)', value: displayProductSuppliers.value })
}
if (displayProductCategory.value) {
rows.push({ label: 'Catégorie', value: displayProductCategory.value })
}
return rows
})
const productDocuments = computed(() => {
const product = props.component.product
return Array.isArray(product?.documents) ? product.documents : []
})
const componentConstructeurIds = computed(() => const componentConstructeurIds = computed(() =>
uniqueConstructeurIds( uniqueConstructeurIds(
props.component, props.component,
@@ -651,332 +549,8 @@ const componentConstructeursDisplay = computed(() =>
const formatConstructeurContact = (constructeur) => const formatConstructeurContact = (constructeur) =>
formatConstructeurContactSummary(constructeur) formatConstructeurContactSummary(constructeur)
const extractStructureCustomFields = (structure) => {
if (!structure || typeof structure !== 'object') {
return []
}
const customFields = structure.customFields
return Array.isArray(customFields) ? customFields : []
}
function fieldKeyFromNameAndType(name, type) {
const normalizedName =
typeof name === 'string' ? name.trim().toLowerCase() : ''
const normalizedType =
typeof type === 'string' ? type.trim().toLowerCase() : ''
return normalizedName ? `${normalizedName}::${normalizedType}` : null
}
function resolveOrderIndex(field) {
if (!field || typeof field !== 'object') {
return 0
}
if (typeof field.orderIndex === 'number') {
return field.orderIndex
}
if (
field.customField &&
typeof field.customField.orderIndex === 'number'
) {
return field.customField.orderIndex
}
return 0
}
function deduplicateFieldDefinitions(definitions) {
const result = []
const seen = new Set()
const orderedDefinitions = (Array.isArray(definitions)
? definitions.slice()
: []
).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
orderedDefinitions.forEach((field) => {
if (!field || typeof field !== 'object') {
return
}
const id =
field.id ??
field.customFieldId ??
field.customField?.id ??
null
const nameKey = fieldKeyFromNameAndType(field.name, field.type)
if (!id && !nameKey) {
return
}
const key = id || nameKey
if (key && seen.has(key)) {
return
}
if (key) {
seen.add(key)
}
field.orderIndex = resolveOrderIndex(field)
result.push(field)
})
return result
}
function mergeFieldDefinitionsWithValues(definitions, values) {
const definitionList = Array.isArray(definitions) ? definitions : []
const valueList = Array.isArray(values) ? values : []
const valueMap = new Map()
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return
}
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
if (fieldId) {
valueMap.set(fieldId, entry)
}
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
if (nameKey) {
valueMap.set(nameKey, entry)
}
})
const merged = definitionList.map((field) => {
if (!field || typeof field !== 'object') {
return field
}
const fieldId = ensureCustomFieldId(field)
const nameKey = fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field))
const matchedValue =
(fieldId ? valueMap.get(fieldId) : undefined) ??
(nameKey ? valueMap.get(nameKey) : undefined)
if (!matchedValue) {
return {
...field,
value: field?.value ?? '',
orderIndex: resolveOrderIndex(field),
}
}
const resolvedOrder = Math.min(
resolveOrderIndex(field),
resolveOrderIndex(matchedValue.customField),
)
return {
...field,
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
customFieldId:
matchedValue.customField?.id ??
matchedValue.customFieldId ??
fieldId ??
null,
customField: matchedValue.customField ?? field.customField ?? null,
value: matchedValue.value ?? field.value ?? '',
orderIndex: resolvedOrder,
}
})
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return
}
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null
const nameKey = fieldKeyFromNameAndType(entry.customField?.name, entry.customField?.type)
const exists = merged.some((field) => {
if (!field || typeof field !== 'object') {
return false
}
if (field.customFieldValueId && field.customFieldValueId === entry.id) {
return true
}
const existingId = ensureCustomFieldId(field)
if (fieldId && existingId && existingId === fieldId) {
return true
}
if (!fieldId && nameKey) {
return (
fieldKeyFromNameAndType(resolveFieldName(field), resolveFieldType(field)) === nameKey
)
}
return false
})
if (!exists) {
merged.push({
customFieldValueId: entry.id ?? null,
customFieldId: fieldId,
name: entry.customField?.name ?? '',
type: entry.customField?.type ?? 'text',
required: entry.customField?.required ?? false,
options: entry.customField?.options ?? [],
value: entry.value ?? '',
customField: entry.customField ?? null,
orderIndex: resolveOrderIndex(entry.customField),
})
}
})
return merged.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
}
function dedupeMergedFields(fields) {
if (!Array.isArray(fields) || fields.length <= 1) {
return Array.isArray(fields)
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
: []
}
const seen = new Map()
const result = []
const orderedFields = fields
.slice()
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
orderedFields.forEach((field) => {
if (!field || typeof field !== 'object') {
return
}
const rawName = resolveFieldName(field)
const normalizedName = typeof rawName === 'string' ? rawName.trim() : ''
if (!normalizedName) {
return
}
field.name = normalizedName
field.type = resolveFieldType(field)
const fieldId = ensureCustomFieldId(field)
const nameKey = fieldKeyFromNameAndType(normalizedName, field.type)
const key = fieldId || nameKey
if (!key) {
field.orderIndex = resolveOrderIndex(field)
result.push(field)
return
}
const existing = seen.get(key)
if (!existing) {
field.orderIndex = resolveOrderIndex(field)
seen.set(key, field)
result.push(field)
return
}
const existingHasValue =
existing.value !== undefined &&
existing.value !== null &&
String(existing.value).trim().length > 0
const incomingHasValue =
field.value !== undefined &&
field.value !== null &&
String(field.value).trim().length > 0
if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field)
existing.orderIndex = Math.min(
resolveOrderIndex(existing),
resolveOrderIndex(field),
)
seen.set(key, existing)
}
})
return result.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
}
const componentDefinitionSources = computed(() => {
const requirement = props.component.typeMachineComponentRequirement || {}
const type = requirement.typeComposant || props.component.typeComposant || {}
const definitions = []
const pushFields = (collection) => {
if (Array.isArray(collection)) {
definitions.push(...collection)
}
}
pushFields(props.component.customFields)
pushFields(props.component.definition?.customFields)
pushFields(type.customFields)
pushFields(requirement.customFields)
pushFields(requirement.definition?.customFields)
;[
props.component.definition?.structure,
type.structure,
type.componentSkeleton,
requirement.structure,
requirement.componentSkeleton,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure)
if (fields.length) {
definitions.push(...fields)
}
})
return deduplicateFieldDefinitions(definitions)
})
const displayedCustomFields = computed(() =>
dedupeMergedFields(
mergeFieldDefinitionsWithValues(
componentDefinitionSources.value,
props.component.customFieldValues,
),
),
)
const candidateCustomFields = computed(() => {
const map = new Map()
const register = (collection) => {
if (!Array.isArray(collection)) {
return
}
collection.forEach((item) => {
if (!item || typeof item !== 'object') {
return
}
const id = item.id || item.customFieldId
const name = typeof item.name === 'string' ? item.name : null
const key = id || (name ? `${name}::${item.type ?? ''}` : null)
if (!key || map.has(key)) {
return
}
map.set(key, item)
})
}
register(props.component.customFieldValues?.map((value) => value?.customField))
register(componentDefinitionSources.value)
return Array.from(map.values())
})
watch(
candidateCustomFields,
() => {
displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
},
{ immediate: true, deep: true }
)
watch(
displayedCustomFields,
(fields) => {
(fields || []).forEach((field) => ensureCustomFieldId(field))
},
{ immediate: true, deep: true }
)
const handleConstructeurChange = async (value) => { const handleConstructeurChange = async (value) => {
const ids = uniqueConstructeurIds(value) const ids = uniqueConstructeurIds(value)
props.component.constructeurIds = [...ids] props.component.constructeurIds = [...ids]
props.component.constructeurId = null props.component.constructeurId = null
props.component.constructeur = null props.component.constructeur = null
@@ -985,42 +559,10 @@ const handleConstructeurChange = async (value) => {
constructeurs.value, constructeurs.value,
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [], Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
) )
await updateComponent() await updateComponent()
} }
const { uploadDocuments, deleteDocument, loadDocumentsByComponent } = useDocuments() // --- Update / Event forwarding ---
const {
updateCustomFieldValue: updateComponentCustomFieldValueApi,
upsertCustomFieldValue: upsertComponentCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
watch(
() => props.toggleToken,
() => {
isCollapsed.value = props.collapseAll
if (!isCollapsed.value) {
ensureDocumentsLoaded()
}
},
{ immediate: true }
)
watch(
() => props.component.documents,
(docs) => {
documentsLoaded.value = !!(docs && docs.length)
}
)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
if (!isCollapsed.value) {
ensureDocumentsLoaded()
}
}
const updateComponent = () => { const updateComponent = () => {
emit('update', { emit('update', {
...props.component, ...props.component,
@@ -1028,216 +570,6 @@ const updateComponent = () => {
}) })
} }
function resolveFieldKey(field, index) {
return field?.id
?? field?.customFieldValueId
?? field?.customFieldId
?? field?.name
?? `field-${index}`
}
function resolveFieldId(field) {
return field?.customFieldValueId ?? null
}
function resolveFieldName(field) {
return field?.name ?? 'Champ'
}
function resolveFieldType(field) {
return field?.type ?? 'text'
}
function resolveFieldOptions(field) {
return field?.options ?? []
}
function resolveFieldRequired(field) {
return !!field?.required
}
function resolveFieldReadOnly(field) {
return !!field?.readOnly
}
function buildCustomFieldMetadata(field) {
return {
customFieldName: resolveFieldName(field),
customFieldType: resolveFieldType(field),
customFieldRequired: resolveFieldRequired(field),
customFieldOptions: resolveFieldOptions(field)
}
}
function resolveCustomFieldId(field) {
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
}
function ensureCustomFieldId(field) {
const existingId = resolveCustomFieldId(field)
if (existingId) {
return existingId
}
const name = resolveFieldName(field)
if (!name || name === 'Champ') {
return null
}
const matches = candidateCustomFields.value.filter((candidate) => {
if (!candidate || typeof candidate !== 'object') {
return false
}
const candidateId = candidate.id || candidate.customFieldId
if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) {
return true
}
return typeof candidate.name === 'string' && candidate.name === name
})
if (matches.length) {
const withId = matches.find((candidate) => candidate?.id || candidate?.customFieldId) || matches[0]
const id = withId?.id || withId?.customFieldId || null
if (id) {
field.customFieldId = id
}
if (!field.customField && typeof withId === 'object') {
field.customField = withId
}
return id
}
return null
}
watch(
candidateCustomFields,
() => {
displayedCustomFields.value.forEach((field) => ensureCustomFieldId(field))
},
{ immediate: true, deep: true }
)
watch(
displayedCustomFields,
(fields) => {
(fields || []).forEach((field) => ensureCustomFieldId(field))
},
{ immediate: true, deep: true }
)
const formatFieldDisplayValue = (field) => {
const type = resolveFieldType(field)
const rawValue = field?.value ?? ''
if (type === 'boolean') {
const normalized = String(rawValue).toLowerCase()
if (normalized === 'true') return 'Oui'
if (normalized === 'false') return 'Non'
}
return rawValue || 'Non défini'
}
const updateComponentCustomField = async (field) => {
if (!field || resolveFieldReadOnly(field)) {
return
}
const fieldValueId = resolveFieldId(field)
if (fieldValueId) {
const result = await updateComponentCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
const existingValue = props.component.customFieldValues?.find((value) => value.id === fieldValueId)
if (existingValue?.customField?.id) {
field.customFieldId = existingValue.customField.id
field.customField = existingValue.customField
}
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
}
return
}
const customFieldId = ensureCustomFieldId(field)
const fieldName = resolveFieldName(field)
if (!props.component?.id) {
showError('Impossible de créer la valeur pour ce champ de composant')
return
}
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
showError('Impossible de créer la valeur pour ce champ de composant')
return
}
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
const result = await upsertComponentCustomFieldValue(
customFieldId,
'composant',
props.component.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
field.customField = newValue.customField
}
if (Array.isArray(props.component.customFieldValues)) {
const index = props.component.customFieldValues.findIndex((value) => value.id === newValue.id)
if (index !== -1) {
props.component.customFieldValues.splice(index, 1, newValue)
} else {
props.component.customFieldValues.push(newValue)
}
} else {
props.component.customFieldValues = [newValue]
}
}
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
const definitions = Array.isArray(props.component.customFields)
? [...props.component.customFields]
: []
const fieldIdentifier = ensureCustomFieldId(field)
const existingIndex = definitions.findIndex((definition) => {
const definitionId = ensureCustomFieldId(definition)
if (fieldIdentifier && definitionId) {
return definitionId === fieldIdentifier
}
return definition?.name === resolveFieldName(field)
})
const updatedDefinition = {
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
customFieldValueId: field.customFieldValueId,
customFieldId: fieldIdentifier,
name: resolveFieldName(field),
type: resolveFieldType(field),
required: resolveFieldRequired(field),
options: resolveFieldOptions(field),
value: field.value ?? '',
customField: field.customField ?? null,
}
if (existingIndex !== -1) {
definitions.splice(existingIndex, 1, updatedDefinition)
} else {
definitions.push(updatedDefinition)
}
props.component.customFields = definitions
} else {
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
}
}
const updatePiece = (updatedPiece) => { const updatePiece = (updatedPiece) => {
emit('edit-piece', updatedPiece) emit('edit-piece', updatedPiece)
} }
@@ -1250,87 +582,4 @@ const updatePieceCustomField = (fieldUpdate) => {
emit('custom-field-update', fieldUpdate) emit('custom-field-update', fieldUpdate)
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' }) emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
} }
const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !props.component?.id) { return }
await refreshDocuments()
}
const refreshDocuments = async () => {
loadingDocuments.value = true
try {
const result = await loadDocumentsByComponent(props.component.id, { updateStore: false })
if (result.success) {
props.component.documents = result.data || []
documentsLoaded.value = true
}
} finally {
loadingDocuments.value = false
}
}
const handleFilesAdded = async (files) => {
if (!files.length || !props.component?.id) { return }
uploadingDocuments.value = true
try {
const result = await uploadDocuments(
{
files,
context: { composantId: props.component.id }
},
{ updateStore: false }
)
if (result.success) {
const newDocs = result.data || []
props.component.documents = [...newDocs, ...(props.component.documents || [])]
documentsLoaded.value = true
selectedFiles.value = []
}
} finally {
uploadingDocuments.value = false
}
}
const removeDocument = async (documentId) => {
if (!documentId) { return }
const result = await deleteDocument(documentId, { updateStore: false })
if (result.success) {
props.component.documents = (props.component.documents || []).filter(doc => doc.id !== documentId)
}
}
const downloadDocument = (doc) => {
if (!doc?.path) { return }
if (doc.path.startsWith('data:')) {
const link = document.createElement('a')
link.href = doc.path
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(doc.path, '_blank')
}
const openPreview = (doc) => {
if (!canPreviewDocument(doc)) { return }
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
const formatSize = (size) => {
if (size === undefined || size === null) { return '—' }
if (size === 0) { return '0 B' }
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
</script> </script>

View File

@@ -10,6 +10,7 @@
: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>
@@ -55,6 +56,10 @@ 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'])
@@ -110,7 +115,7 @@ const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) =
} }
if (Array.isArray(node.customFields)) { if (Array.isArray(node.customFields)) {
node.customFields = node.customFields.map((field: any) => { node.customFields = node.customFields.map((field: Record<string, any>) => {
if (!field || typeof field !== 'object') { if (!field || typeof field !== 'object') {
return field return field
} }
@@ -140,7 +145,7 @@ const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) =
} }
if (Array.isArray(node.subcomponents)) { if (Array.isArray(node.subcomponents)) {
node.subcomponents = node.subcomponents.map((sub: any) => { node.subcomponents = node.subcomponents.map((sub: Record<string, any>) => {
if (!sub || typeof sub !== 'object') { if (!sub || typeof sub !== 'object') {
return sub return sub
} }
@@ -241,7 +246,7 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
const loaders: Promise<any>[] = [] const loaders: Promise<unknown>[] = []
if (!availablePieceTypes.value.length) { if (!availablePieceTypes.value.length) {
loaders.push(loadPieceTypes()) loaders.push(loadPieceTypes())
} }

View File

@@ -137,6 +137,7 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import SearchSelect from '~/components/common/SearchSelect.vue'; import SearchSelect from '~/components/common/SearchSelect.vue';
import { useApi } from '~/composables/useApi'; import { useApi } from '~/composables/useApi';
import { extractCollection } from '~/shared/utils/apiHelpers';
import type { import type {
ComponentModelPiece, ComponentModelPiece,
ComponentModelProduct, ComponentModelProduct,
@@ -243,22 +244,6 @@ const pieceLoadingByPath = ref<Record<string, boolean>>({});
const productLoadingByPath = ref<Record<string, boolean>>({}); const productLoadingByPath = ref<Record<string, boolean>>({});
const componentLoadingByPath = ref<Record<string, boolean>>({}); const componentLoadingByPath = ref<Record<string, boolean>>({});
const extractCollection = (payload: any): any[] => {
if (Array.isArray(payload)) {
return payload;
}
if (Array.isArray(payload?.member)) {
return payload.member;
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member'];
}
if (Array.isArray(payload?.data)) {
return payload.data;
}
return [];
};
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => { const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
target[key] = value; target[key] = value;
}; };
@@ -362,7 +347,7 @@ const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = ''
const definition = assignment.definition || {}; const definition = assignment.definition || {};
const requiredTypeId = const requiredTypeId =
definition.typePieceId || (definition as any).typePiece?.id || null; definition.typePieceId || definition.typePiece?.id || null;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('itemsPerPage', '50'); params.set('itemsPerPage', '50');
@@ -392,7 +377,7 @@ const fetchProductOptions = async (assignment: StructureProductAssignment, term
const definition = assignment.definition || {}; const definition = assignment.definition || {};
const requiredTypeId = const requiredTypeId =
definition.typeProductId || (definition as any).typeProduct?.id || null; definition.typeProductId || definition.typeProduct?.id || null;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('itemsPerPage', '50'); params.set('itemsPerPage', '50');
@@ -447,14 +432,14 @@ const describePieceRequirement = (assignment: StructurePieceAssignment) => {
addPart(definition.role); addPart(definition.role);
const explicitLabel = const explicitLabel =
definition.typePieceLabel || definition.typePieceLabel ||
(definition as any).typePiece?.name || definition.typePiece?.name ||
(definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) || (definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
fallbackType?.name; fallbackType?.name;
addPart(explicitLabel); addPart(explicitLabel);
const family = const family =
definition.familyCode || definition.familyCode ||
(definition as any).typePiece?.code || definition.typePiece?.code ||
fallbackType?.code || fallbackType?.code ||
null; null;
if (family) { if (family) {
@@ -483,7 +468,7 @@ const getProductOptions = (assignment: StructureProductAssignment) => {
const definition = assignment.definition; const definition = assignment.definition;
const requiredTypeId = const requiredTypeId =
definition.typeProductId || definition.typeProductId ||
(definition as any).typeProduct?.id || definition.typeProduct?.id ||
definition.familyCode || definition.familyCode ||
null; null;
@@ -494,7 +479,7 @@ const getProductOptions = (assignment: StructureProductAssignment) => {
if (!requiredTypeId) { if (!requiredTypeId) {
return true; return true;
} }
if (definition.typeProductId || (definition as any).typeProduct?.id) { if (definition.typeProductId || definition.typeProduct?.id) {
return ( return (
product.typeProductId === requiredTypeId || product.typeProductId === requiredTypeId ||
product.typeProduct?.id === requiredTypeId product.typeProduct?.id === requiredTypeId
@@ -550,14 +535,14 @@ const describeProductRequirement = (assignment: StructureProductAssignment) => {
addPart(definition.role); addPart(definition.role);
const explicitLabel = const explicitLabel =
definition.typeProductLabel || definition.typeProductLabel ||
(definition as any).typeProduct?.name || definition.typeProduct?.name ||
(definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) || (definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
fallbackType?.name; fallbackType?.name;
addPart(explicitLabel); addPart(explicitLabel);
const family = const family =
definition.familyCode || definition.familyCode ||
(definition as any).typeProduct?.code || definition.typeProduct?.code ||
fallbackType?.code || fallbackType?.code ||
null; null;
if (family) { if (family) {
@@ -617,7 +602,7 @@ const getPieceOptions = (assignment: StructurePieceAssignment) => {
const definition = assignment.definition; const definition = assignment.definition;
const requiredTypeId = const requiredTypeId =
definition.typePieceId || definition.typePieceId ||
(definition as any).typePiece?.id || definition.typePiece?.id ||
definition.familyCode || definition.familyCode ||
null; null;
@@ -628,7 +613,7 @@ const getPieceOptions = (assignment: StructurePieceAssignment) => {
if (!requiredTypeId) { if (!requiredTypeId) {
return true; return true;
} }
if (definition.typePieceId || (definition as any).typePiece?.id) { if (definition.typePieceId || definition.typePiece?.id) {
return ( return (
piece.typePieceId === requiredTypeId || piece.typePieceId === requiredTypeId ||
piece.typePiece?.id === requiredTypeId piece.typePiece?.id === requiredTypeId

View File

@@ -246,6 +246,16 @@ const emitSelection = (ids: string[]) => {
emit('update:modelValue', normalized) emit('update:modelValue', normalized)
} }
const extractDataArray = (data: unknown): ConstructeurSummary[] => {
if (Array.isArray(data)) {
return data as ConstructeurSummary[]
}
if (data && typeof data === 'object' && 'id' in data) {
return [data as ConstructeurSummary]
}
return []
}
const ensureOptionsLoaded = async (force = false) => { const ensureOptionsLoaded = async (force = false) => {
if (!force && !searchTerm.value && constructeurs.value.length) { if (!force && !searchTerm.value && constructeurs.value.length) {
applyOptions(constructeurs.value as ConstructeurSummary[]) applyOptions(constructeurs.value as ConstructeurSummary[])
@@ -262,7 +272,7 @@ const ensureOptionsLoaded = async (force = false) => {
const result = await searchConstructeurs(searchTerm.value) const result = await searchConstructeurs(searchTerm.value)
if (result.success) { if (result.success) {
applyOptions(result.data || []) applyOptions(extractDataArray(result.data))
lastSearchTerm = searchTerm.value lastSearchTerm = searchTerm.value
} }
} }
@@ -283,7 +293,7 @@ const onSearch = () => {
} }
const result = await searchConstructeurs(searchTerm.value) const result = await searchConstructeurs(searchTerm.value)
if (result.success) { if (result.success) {
applyOptions(result.data || []) applyOptions(extractDataArray(result.data))
lastSearchTerm = searchTerm.value lastSearchTerm = searchTerm.value
} }
}, 250) }, 250)
@@ -310,16 +320,18 @@ const closeCreateModal = () => {
const handleCreate = async () => { const handleCreate = async () => {
creating.value = true creating.value = true
const payload = { ...createForm.value } const payload: { name: string; email?: string; phone?: string } = {
if (!payload.phone) { name: createForm.value.name,
delete payload.phone
} }
if (!payload.email) { if (createForm.value.email) {
delete payload.email payload.email = createForm.value.email
}
if (createForm.value.phone) {
payload.phone = createForm.value.phone
} }
const result = await createConstructeur(payload) const result = await createConstructeur(payload)
creating.value = false creating.value = false
if (result.success) { if (result.success && result.data && !Array.isArray(result.data)) {
emitSelection([...selectedIds.value, result.data.id]) emitSelection([...selectedIds.value, result.data.id])
searchTerm.value = '' searchTerm.value = ''
closeCreateModal() closeCreateModal()

View File

@@ -81,7 +81,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, watch, computed } from 'vue' import { reactive, onMounted, watch, computed } from 'vue'
const props = defineProps({ const props = defineProps({
customFields: { customFields: {

View File

@@ -103,7 +103,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
const props = defineProps({ defineProps({
isOpen: { isOpen: {
type: Boolean, type: Boolean,
default: false default: false

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,10 @@
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 safficheront lors de la création dune 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> </div>
<button type="button" class="btn btn-outline btn-xs" @click="addProduct"> <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" /> <IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter Ajouter
</button> </button>
@@ -35,6 +35,7 @@
<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="">
@@ -51,12 +52,22 @@
</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>
@@ -108,7 +119,7 @@
class="input input-bordered input-xs" class="input input-bordered input-xs"
placeholder="Nom du champ" placeholder="Nom du champ"
> >
<select v-model="field.type" class="select select-bordered select-xs"> <select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<option value="text"> <option value="text">
Texte Texte
</option> </option>
@@ -128,7 +139,7 @@
</div> </div>
<div class="flex items-center gap-2 text-xs"> <div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs"> <input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
Obligatoire Obligatoire
</div> </div>
@@ -137,16 +148,27 @@
v-model="field.optionsText" v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20" class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2" placeholder="Option 1&#10;Option 2"
:disabled="isFieldLocked(field)"
/> />
</div> </div>
<button <button
v-if="!isFieldLocked(field)"
type="button" type="button"
class="btn btn-error btn-xs btn-square" class="btn btn-error btn-xs btn-square"
@click="removeField(index)" @click="removeField(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 champ 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>
@@ -181,6 +203,7 @@ type EditorProduct = {
const props = defineProps<{ const props = defineProps<{
modelValue?: PieceModelStructure | null modelValue?: PieceModelStructure | null
restrictedMode?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -330,6 +353,19 @@ const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue)) const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
const restState = ref<Record<string, unknown>>(extractRest(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[] => const applyOrderIndex = (list: EditorField[]): EditorField[] =>
list.map((field, index) => ({ list.map((field, index) => ({
...field, ...field,
@@ -438,6 +474,8 @@ watch(
products.value = hydrateProducts(value) products.value = hydrateProducts(value)
products.value.forEach((product) => updateProductTypeMetadata(product)) products.value.forEach((product) => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized lastEmitted = incomingSerialized
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
initialProductUids.value = new Set(products.value.map(p => p.uid))
}, },
{ deep: true }, { deep: true },
) )
@@ -472,6 +510,10 @@ const reorderFields = (from: number, to: number) => {
} }
const [moved] = list.splice(from, 1) const [moved] = list.splice(from, 1)
if (!moved) {
resetDragState()
return
}
list.splice(to, 0, moved) list.splice(to, 0, moved)
fields.value = applyOrderIndex(list) fields.value = applyOrderIndex(list)
resetDragState() resetDragState()

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="space-y-1"> <div class="space-y-1">
<SearchSelect <SearchSelect
:model-value="modelValue" :model-value="modelValue ?? undefined"
:options="productOptions" :options="productOptions"
:loading="loading" :loading="loading"
:placeholder="placeholder" :placeholder="placeholder"

View File

@@ -17,6 +17,7 @@
<select <select
v-model="node.typeComposantId" v-model="node.typeComposantId"
class="select select-bordered select-sm w-full" class="select select-bordered select-sm w-full"
:disabled="isLocked"
@change="handleComponentTypeSelect(node)" @change="handleComponentTypeSelect(node)"
> >
<option value=""> <option value="">
@@ -42,6 +43,7 @@
type="text" type="text"
class="input input-bordered input-xs" class="input input-bordered input-xs"
placeholder="Alias du sous-composant" placeholder="Alias du sous-composant"
:disabled="isLocked"
/> />
</div> </div>
</template> </template>
@@ -52,13 +54,18 @@
</template> </template>
</div> </div>
<button <button
v-if="!isRoot" v-if="!isRoot && !isLocked"
type="button" type="button"
class="btn btn-error btn-xs btn-square" class="btn btn-error btn-xs btn-square"
@click="emit('remove')" @click="emit('remove')"
> >
<IconLucideTrash class="w-4 h-4" aria-hidden="true" /> <IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button> </button>
<div v-else-if="!isRoot && isLocked" class="tooltip tooltip-left" data-tip="Ce sous-composant ne peut pas être supprimé">
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div> </div>
<div class="px-4 py-4 space-y-5"> <div class="px-4 py-4 space-y-5">
@@ -108,7 +115,7 @@
class="input input-bordered input-xs" class="input input-bordered input-xs"
placeholder="Nom du champ" placeholder="Nom du champ"
/> />
<select v-model="field.type" class="select select-bordered select-xs"> <select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
<option value="text">Texte</option> <option value="text">Texte</option>
<option value="number">Nombre</option> <option value="number">Nombre</option>
<option value="select">Liste</option> <option value="select">Liste</option>
@@ -117,7 +124,7 @@
</select> </select>
</div> </div>
<div class="flex items-center gap-2 text-xs"> <div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" /> <input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isCustomFieldLocked(index)" />
Obligatoire Obligatoire
</div> </div>
<textarea <textarea
@@ -125,15 +132,26 @@
v-model="field.optionsText" v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20" class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2" placeholder="Option 1&#10;Option 2"
:disabled="isCustomFieldLocked(index)"
></textarea> ></textarea>
</div> </div>
<button <button
v-if="!isCustomFieldLocked(index)"
type="button" type="button"
class="btn btn-error btn-xs btn-square" class="btn btn-error btn-xs btn-square"
@click="removeCustomField(index)" @click="removeCustomField(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 champ 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>
</div> </div>
</div> </div>
@@ -144,7 +162,7 @@
<h4 :class="headingClass"> <h4 :class="headingClass">
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }} {{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
</h4> </h4>
<button type="button" class="btn btn-outline btn-xs" @click="addProduct"> <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" /> <IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter Ajouter
</button> </button>
@@ -179,6 +197,7 @@
<select <select
v-model="product.typeProductId" v-model="product.typeProductId"
class="select select-bordered select-xs" class="select select-bordered select-xs"
:disabled="isProductLocked(index)"
@change="handleProductTypeSelect(product)" @change="handleProductTypeSelect(product)"
> >
<option value=""> <option value="">
@@ -194,9 +213,18 @@
</select> </select>
</div> </div>
</div> </div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)"> <button v-if="!isProductLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @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>
</div> </div>
</div> </div>
@@ -207,7 +235,7 @@
<h4 :class="headingClass"> <h4 :class="headingClass">
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }} {{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
</h4> </h4>
<button type="button" class="btn btn-outline btn-xs" @click="addPiece"> <button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" /> <IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter Ajouter
</button> </button>
@@ -243,6 +271,7 @@
<select <select
v-model="piece.typePieceId" v-model="piece.typePieceId"
class="select select-bordered select-xs" class="select select-bordered select-xs"
:disabled="isPieceLocked(index)"
@change="handlePieceTypeSelect(piece)" @change="handlePieceTypeSelect(piece)"
> >
<option value=""> <option value="">
@@ -262,9 +291,14 @@
</p> </p>
</div> </div>
</div> </div>
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)"> <button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(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="Cette pièce ne peut pas être supprimée">
<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>
</div> </div>
</div> </div>
@@ -274,7 +308,7 @@
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<h4 :class="headingClass">Sous-composants</h4> <h4 :class="headingClass">Sous-composants</h4>
<button <button
v-if="canManageSubcomponents" v-if="canManageSubcomponents && !restrictedMode"
type="button" type="button"
class="btn btn-outline btn-xs" class="btn btn-outline btn-xs"
@click="addSubComponent" @click="addSubComponent"
@@ -317,6 +351,8 @@
:product-types="productTypes" :product-types="productTypes"
:allow-subcomponents="childAllowSubcomponents" :allow-subcomponents="childAllowSubcomponents"
:max-subcomponent-depth="maxSubcomponentDepth" :max-subcomponent-depth="maxSubcomponentDepth"
:restricted-mode="restrictedMode"
:is-locked="isSubcomponentLocked(index)"
@remove="removeSubComponent(index)" @remove="removeSubComponent(index)"
/> />
</div> </div>
@@ -359,6 +395,8 @@ const props = withDefaults(defineProps<{
lockedTypeLabel?: string lockedTypeLabel?: string
allowSubcomponents?: boolean allowSubcomponents?: boolean
maxSubcomponentDepth?: number maxSubcomponentDepth?: number
restrictedMode?: boolean
isLocked?: boolean
}>(), { }>(), {
depth: 0, depth: 0,
componentTypes: () => [], componentTypes: () => [],
@@ -369,10 +407,52 @@ const props = withDefaults(defineProps<{
lockedTypeLabel: '', lockedTypeLabel: '',
allowSubcomponents: true, allowSubcomponents: true,
maxSubcomponentDepth: Infinity, maxSubcomponentDepth: Infinity,
restrictedMode: false,
isLocked: false,
}) })
const emit = defineEmits(['remove']) const emit = defineEmits(['remove'])
const initialCustomFieldIndices = ref<Set<number>>(new Set())
const initialPieceIndices = ref<Set<number>>(new Set())
const initialProductIndices = ref<Set<number>>(new Set())
const initialSubcomponentIndices = ref<Set<number>>(new Set())
const initializeLockedIndices = () => {
if (props.restrictedMode) {
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
}
}
initializeLockedIndices()
const isCustomFieldLocked = (index: number): boolean => {
return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
}
const isPieceLocked = (index: number): boolean => {
return props.restrictedMode === true && initialPieceIndices.value.has(index)
}
const isProductLocked = (index: number): boolean => {
return props.restrictedMode === true && initialProductIndices.value.has(index)
}
const isSubcomponentLocked = (index: number): boolean => {
return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
}
const isLocked = computed(() => props.isLocked === true)
const restrictedMode = computed(() => props.restrictedMode === true)
const componentTypes = computed(() => props.componentTypes ?? []) const componentTypes = computed(() => props.componentTypes ?? [])
const pieceTypes = computed(() => props.pieceTypes ?? []) const pieceTypes = computed(() => props.pieceTypes ?? [])
const productTypes = computed(() => props.productTypes ?? []) const productTypes = computed(() => props.productTypes ?? [])
@@ -460,7 +540,7 @@ const getPieceTypeLabel = (id?: string) => {
return formatModelTypeOption(pieceTypeMap.value.get(id)) return formatModelTypeOption(pieceTypeMap.value.get(id))
} }
const getProductTypeLabel = (id?: string) => { const _getProductTypeLabel = (id?: string) => {
if (!id) return '' if (!id) return ''
return formatModelTypeOption(productTypeMap.value.get(id)) return formatModelTypeOption(productTypeMap.value.get(id))
} }
@@ -744,10 +824,9 @@ const customFieldReorderClass = (index: number) => {
const addCustomField = () => { const addCustomField = () => {
ensureArray('customFields') ensureArray('customFields')
const nextIndex = Array.isArray(props.node.customFields) const fields = props.node.customFields!
? props.node.customFields.length const nextIndex = fields.length
: 0 fields.push({
props.node.customFields.push({
name: '', name: '',
type: 'text', type: 'text',
required: false, required: false,
@@ -766,7 +845,7 @@ const removeCustomField = (index: number) => {
const addPiece = () => { const addPiece = () => {
ensureArray('pieces') ensureArray('pieces')
props.node.pieces.push({ props.node.pieces!.push({
typePieceId: '', typePieceId: '',
typePieceLabel: '', typePieceLabel: '',
reference: '', reference: '',
@@ -782,7 +861,7 @@ const removePiece = (index: number) => {
const addProduct = () => { const addProduct = () => {
ensureArray('products') ensureArray('products')
props.node.products.push({ props.node.products!.push({
typeProductId: '', typeProductId: '',
typeProductLabel: '', typeProductLabel: '',
familyCode: '', familyCode: '',
@@ -830,6 +909,7 @@ const moveItemInPlace = <T,>(list: T[], from: number, to: number) => {
} }
const updated = list.slice() const updated = list.slice()
const [item] = updated.splice(from, 1) const [item] = updated.splice(from, 1)
if (item === undefined) return
updated.splice(to, 0, item) updated.splice(to, 0, item)
list.splice(0, list.length, ...updated) list.splice(0, list.length, ...updated)
} }

View File

@@ -12,7 +12,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted } from 'vue'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue' import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
@@ -91,11 +91,5 @@ onMounted(async () => {
if (!pieceTypes.value.length) { if (!pieceTypes.value.length) {
await loadPieceTypes() await loadPieceTypes()
} }
console.log('[PieceRequirementsSection] pieceTypes loaded:', pieceTypes.value.map(t => ({ id: t.id, name: t.name })))
console.log('[PieceRequirementsSection] requirements on mount:', props.modelValue.map(r => ({ id: r.id, typePieceId: r.typePieceId })))
}) })
watch(() => props.modelValue, (newVal) => {
console.log('[PieceRequirementsSection] requirements updated:', newVal.map(r => ({ id: r.id, typePieceId: r.typePieceId })))
}, { deep: true })
</script> </script>

View File

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

View File

@@ -66,7 +66,7 @@
type="text" type="text"
class="input input-bordered input-sm" class="input input-bordered input-sm"
:placeholder="labels.labelPlaceholder" :placeholder="labels.labelPlaceholder"
@input="updateRequirement(index, { label: $event.target.value })" @input="handleLabelInput(index, $event)"
/> />
</div> </div>
@@ -79,7 +79,7 @@
type="number" type="number"
min="0" min="0"
class="input input-bordered input-sm" class="input input-bordered input-sm"
@input="updateRequirement(index, { minCount: parseNumber($event.target.value) })" @input="handleMinInput(index, $event)"
/> />
</div> </div>
@@ -93,7 +93,7 @@
type="number" type="number"
min="0" min="0"
class="input input-bordered input-sm" class="input input-bordered input-sm"
@input="updateRequirement(index, { maxCount: parseOptionalNumber($event.target.value) })" @input="handleMaxInput(index, $event)"
/> />
</div> </div>
</div> </div>
@@ -113,7 +113,7 @@
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="checkbox checkbox-sm"
:checked="(requirement.required ?? requiredFallback) === true" :checked="(requirement.required ?? requiredFallback) === true"
@change="updateRequirement(index, { required: $event.target.checked })" @change="handleRequiredChange(index, $event)"
/> />
{{ labels.requiredLabel }} {{ labels.requiredLabel }}
</label> </label>
@@ -123,7 +123,7 @@
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="checkbox checkbox-sm"
:checked="(requirement.allowNewModels ?? allowNewModelsFallback) === true" :checked="(requirement.allowNewModels ?? allowNewModelsFallback) === true"
@change="updateRequirement(index, { allowNewModels: $event.target.checked })" @change="handleAllowNewModelsChange(index, $event)"
/> />
{{ labels.allowNewModelsLabel }} {{ labels.allowNewModelsLabel }}
</label> </label>
@@ -277,6 +277,37 @@ const parseOptionalNumber = (value: string) => {
return Number.isFinite(parsed) ? parsed : null return Number.isFinite(parsed) ? parsed : null
} }
// Type-safe event handlers
const getInputValue = (event: Event): string => {
const target = event.target as HTMLInputElement | null
return target?.value ?? ''
}
const getCheckboxValue = (event: Event): boolean => {
const target = event.target as HTMLInputElement | null
return target?.checked ?? false
}
const handleLabelInput = (index: number, event: Event) => {
updateRequirement(index, { label: getInputValue(event) })
}
const handleMinInput = (index: number, event: Event) => {
updateRequirement(index, { minCount: parseNumber(getInputValue(event)) })
}
const handleMaxInput = (index: number, event: Event) => {
updateRequirement(index, { maxCount: parseOptionalNumber(getInputValue(event)) })
}
const handleRequiredChange = (index: number, event: Event) => {
updateRequirement(index, { required: getCheckboxValue(event) })
}
const handleAllowNewModelsChange = (index: number, event: Event) => {
updateRequirement(index, { allowNewModels: getCheckboxValue(event) })
}
const draggingRequirementIndex = ref<number | null>(null) const draggingRequirementIndex = ref<number | null>(null)
const requirementDropTargetIndex = ref<number | null>(null) const requirementDropTargetIndex = ref<number | null>(null)
@@ -297,6 +328,10 @@ const reorderRequirements = (from: number, to: number) => {
} }
const updated = list.slice() as Requirement[] const updated = list.slice() as Requirement[]
const [moved] = updated.splice(from, 1) const [moved] = updated.splice(from, 1)
if (!moved) {
resetRequirementDragState()
return
}
updated.splice(to, 0, moved) updated.splice(to, 0, moved)
requirements.value = applyOrderIndex(updated) requirements.value = applyOrderIndex(updated)
resetRequirementDragState() resetRequirementDragState()

View File

@@ -184,8 +184,7 @@ watch(
watch( watch(
baseOptions, baseOptions,
(newOptions) => { (_newOptions) => {
console.log('[SearchSelect] baseOptions changed, count:', newOptions.length, 'modelValue:', props.modelValue, 'selectedOption:', selectedOption.value?.id)
if (!openDropdown.value && selectedOption.value) { if (!openDropdown.value && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value) searchTerm.value = resolveLabel(selectedOption.value)
} }

View File

@@ -0,0 +1,369 @@
<template>
<div class="navbar bg-base-100 shadow-lg">
<div class="navbar-start">
<!-- Mobile hamburger menu -->
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
>
<li class="pt-1 pb-2 lg:hidden">
<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"
@click="$emit('open-settings')"
>
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
Paramètres d'affichage
</button>
</li>
<!-- Mobile: simple links -->
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
:to="link.to"
class="rounded-md px-2 py-1 transition-colors"
:class="linkClass(link)"
>
{{ link.label }}
</NuxtLink>
</li>
<!-- Mobile: dropdown groups -->
<li
v-for="group in navGroups"
:key="group.id + '-mobile'"
class="mt-1 border-t border-base-200 pt-2"
>
<button
type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-1 text-left transition-colors"
:class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-mobile'"
@click="toggleDropdown(group.id + '-mobile')"
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
>
<span>{{ group.label }}</span>
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-mobile">
<ul
v-if="openDropdown === group.id + '-mobile'"
class="mt-2 space-y-1 rounded-md border border-base-200 bg-base-100 p-2 shadow-sm overflow-hidden"
>
<li v-for="child in group.children" :key="child.to">
<NuxtLink
:to="child.to"
class="rounded-md px-2 py-1 transition-colors block"
:class="childLinkClass(child)"
>
{{ child.label }}
</NuxtLink>
</li>
</ul>
</Transition>
</li>
</ul>
</div>
<!-- Logo -->
<div class="flex items-center space-x-3">
<div class="avatar">
<div class="w-14">
<img
:src="logoSrc"
alt="Logo Malio"
class="h-full w-full object-contain"
/>
</div>
</div>
<NuxtLink to="/" class="btn btn-ghost text-xl">
Inventory
</NuxtLink>
</div>
</div>
<!-- Desktop navbar -->
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<!-- Desktop: simple links -->
<li v-for="link in simpleLinks" :key="link.to">
<NuxtLink
:to="link.to"
class="transition-colors px-3 py-2 rounded-md"
:class="linkClass(link)"
>
{{ link.label }}
</NuxtLink>
</li>
<!-- Desktop: dropdown groups -->
<li
v-for="group in navGroups"
:key="group.id + '-desktop'"
class="relative"
@mouseenter="setDropdown(group.id + '-desktop')"
@mouseleave="scheduleDropdownClose(group.id + '-desktop')"
@focusin="setDropdown(group.id + '-desktop')"
@focusout="scheduleDropdownClose(group.id + '-desktop')"
>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
:class="groupClass(group)"
:aria-expanded="openDropdown === group.id + '-desktop'"
@click="toggleDropdown(group.id + '-desktop')"
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
>
{{ group.label }}
<IconLucideChevronRight
class="h-4 w-4 transition-transform"
:class="openDropdown === group.id + '-desktop' ? 'rotate-90' : ''"
aria-hidden="true"
/>
</button>
<Transition name="nav-dropdown-desktop">
<ul
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"
>
<li v-for="child in group.children" :key="child.to">
<NuxtLink
:to="child.to"
class="block rounded-md px-2 py-1 transition-colors"
:class="childLinkClass(child)"
>
{{ child.label }}
</NuxtLink>
</li>
</ul>
</Transition>
</li>
</ul>
</div>
<!-- Navbar end -->
<div class="navbar-end">
<div class="flex items-center gap-2">
<button
class="btn btn-ghost btn-circle hidden lg:inline-flex"
title="Paramètres d'affichage"
@click="$emit('open-settings')"
>
<IconLucideSettings class="w-5 h-5" aria-hidden="true" />
</button>
<ClientOnly>
<div v-if="activeProfile" class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="btn btn-ghost btn-circle avatar placeholder"
>
<div
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
>
<span
class="flex h-full w-full items-center justify-center text-sm font-semibold leading-none tracking-tight"
>
{{ activeProfileInitials }}
</span>
</div>
</div>
<ul
tabindex="0"
class="menu dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-64"
>
<li class="px-2 py-1 text-sm text-base-content/70">
Connecté en tant que<br />
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
</li>
<li>
<NuxtLink to="/profiles/manage" class="justify-between">
Gestion des profils
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
</NuxtLink>
</li>
<li>
<button
type="button"
class="text-error justify-between"
@click="$emit('logout')"
>
Déconnexion
<IconLucideLogOut class="w-4 h-4" aria-hidden="true" />
</button>
</li>
</ul>
</div>
</ClientOnly>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from '#imports'
import { useNavDropdown } from '~/composables/useNavDropdown'
import { useProfileSession } from '~/composables/useProfileSession'
import IconLucideMenu from '~icons/lucide/menu'
import IconLucideSettings from '~icons/lucide/settings'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideLogOut from '~icons/lucide/log-out'
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
defineEmits<{
(e: 'open-settings'): void
(e: 'logout'): void
}>()
interface NavLink {
to: string
label: string
}
interface NavGroup {
id: string
label: string
activePaths: string[]
children: NavLink[]
}
const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble' },
{ to: '/machines', label: 'Parc Machines' },
{ to: '/machine-skeleton', label: 'Squelettes de machine' },
]
const navGroups: NavGroup[] = [
{
id: 'pieces',
label: 'Pièces',
activePaths: ['/piece-category', '/pieces-catalog'],
children: [
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
{ to: '/piece-category', label: 'Catégorie de pièce' },
],
},
{
id: 'products',
label: 'Produits',
activePaths: ['/product-category', '/product-catalog'],
children: [
{ to: '/product-catalog', label: 'Catalogue des produits' },
{ 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',
label: 'Ressources liées',
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'],
children: [
{ to: '/sites', label: 'Sites' },
{ to: '/documents', label: 'Documents' },
{ to: '/constructeurs', label: 'Fournisseurs' },
{ to: '/activity-log', label: 'Journal d\'activité' },
],
},
]
const route = useRoute()
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
const { activeProfile } = useProfileSession()
const isActive = (path: string) => {
if (path === '/') {
return route.path === '/'
}
return route.path.startsWith(path)
}
const isGroupActive = (group: NavGroup) => {
return group.activePaths.some((path) => isActive(path))
}
const linkClass = (link: NavLink) => {
return isActive(link.to)
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
}
const groupClass = (group: NavGroup) => {
return isGroupActive(group)
? 'bg-primary text-primary-content font-semibold shadow-sm'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
}
const childLinkClass = (child: NavLink) => {
return isActive(child.to)
? 'bg-primary/10 text-primary font-semibold'
: 'text-base-content hover:bg-primary/10 hover:text-primary'
}
const activeProfileLabel = computed(() => {
if (!activeProfile.value) {
return 'Profil inconnu'
}
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`
})
const activeProfileInitials = computed(() => {
if (!activeProfile.value) {
return '??'
}
const { firstName = '', lastName = '' } = activeProfile.value
return (
`${firstName.charAt(0) || ''}${lastName.charAt(0) || ''}`.toUpperCase() || '??'
)
})
</script>
<style scoped>
.nav-dropdown-desktop-enter-active,
.nav-dropdown-desktop-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.nav-dropdown-desktop-enter-from,
.nav-dropdown-desktop-leave-to {
opacity: 0;
transform: translateY(0.25rem);
}
.nav-dropdown-desktop-enter-to,
.nav-dropdown-desktop-leave-from {
opacity: 1;
transform: translateY(0);
}
.nav-dropdown-mobile-enter-active,
.nav-dropdown-mobile-leave-active {
transition: max-height 0.2s ease, opacity 0.2s ease;
}
.nav-dropdown-mobile-enter-from,
.nav-dropdown-mobile-leave-to {
max-height: 0;
opacity: 0;
}
.nav-dropdown-mobile-enter-to,
.nav-dropdown-mobile-leave-from {
max-height: 12rem;
opacity: 1;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Composants</h2>
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
@click="$emit('toggle-collapse')"
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
>
<IconLucideChevronRight
class="w-5 h-5 transition-transform"
:class="collapsed ? 'rotate-0' : 'rotate-90'"
aria-hidden="true"
/>
<span class="text-sm">
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
</span>
</button>
</div>
<ComponentHierarchy
:components="components"
:is-edit-mode="isEditMode"
:collapse-all="collapsed"
:toggle-token="collapseToggleToken"
@update="$emit('update-component', $event)"
@edit-piece="$emit('edit-piece', $event)"
@custom-field-update="$emit('custom-field-update', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import ComponentHierarchy from '~/components/ComponentHierarchy.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right'
defineProps<{
components: any[]
isEditMode: boolean
collapsed: boolean
collapseToggleToken: number
}>()
defineEmits<{
'toggle-collapse': []
'update-component': [component: any]
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
}>()
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="flex flex-col gap-2">
<h1 class="text-3xl font-bold">
{{ title }}
</h1>
<div 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 class="flex items-center gap-2 print:hidden" data-print-hide>
<button
@click="$emit('toggle-edit')"
class="btn btn-primary"
:class="{ 'btn-outline': isEditMode }"
>
<IconLucideSquarePen
v-if="!isEditMode"
class="w-5 h-5 mr-2"
aria-hidden="true"
/>
<IconLucideEye
v-else
class="w-5 h-5 mr-2"
aria-hidden="true"
/>
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button>
<button
v-if="isDetailsView && !isEditMode"
@click="$emit('open-print')"
type="button"
class="btn btn-outline btn-secondary"
>
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
Imprimer
</button>
</div>
</div>
</template>
<script setup lang="ts">
import IconLucideSquarePen from '~icons/lucide/square-pen'
import IconLucideEye from '~icons/lucide/eye'
import IconLucidePrinter from '~icons/lucide/printer'
defineProps<{
title: string
isDetailsView: boolean
isSkeletonView: boolean
isEditMode: boolean
hasSkeletonRequirements: boolean
}>()
defineEmits<{
'change-view': [view: 'details' | 'skeleton']
'toggle-edit': []
'open-print': []
}>()
</script>

View File

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

View File

@@ -0,0 +1,185 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title">Informations de la machine</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom</span>
</label>
<input
v-if="isEditMode"
:id="getMachineFieldId('name')"
:value="machineName"
type="text"
class="input input-bordered"
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur-field')"
/>
<div v-else class="input input-bordered bg-base-200">
{{ machineName }}
</div>
</div>
<div v-if="isEditMode || machineReference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
:id="getMachineFieldId('reference')"
:value="machineReference"
type="text"
class="input input-bordered"
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur-field')"
/>
<div v-else class="input input-bordered bg-base-200">
{{ machineReference }}
</div>
</div>
<div v-if="isEditMode || hasMachineConstructeur" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
class="w-full"
:model-value="machineConstructeurIds"
:initial-options="machineConstructeursDisplay"
placeholder="Rechercher un ou plusieurs fournisseurs..."
@update:modelValue="$emit('update:constructeur-ids', $event)"
/>
<div v-else class="input input-bordered bg-base-200">
<div v-if="machineConstructeursDisplay.length" class="space-y-1">
<div
v-for="constructeur in machineConstructeursDisplay"
:key="constructeur.id"
class="flex flex-col"
>
<span class="font-medium">{{ constructeur.name }}</span>
<span
v-if="formatConstructeurContactSummary(constructeur)"
class="text-xs text-gray-500"
>
{{ formatConstructeurContactSummary(constructeur) }}
</span>
</div>
</div>
<span v-else class="font-medium">Non défini</span>
</div>
</div>
</div>
<!-- Champs personnalisés -->
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-gray-200">
<h4 class="font-semibold text-gray-700 mb-3">Champs personnalisés de la machine</h4>
<div 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>
<span v-if="field.required" class="label-text-alt text-error">*</span>
</label>
<template v-if="isEditMode">
<input
v-if="field.type === 'text'"
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm"
:required="field.required"
@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"
:required="field.required"
@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"
:required="field.required"
@change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)"
@blur="$emit('update-custom-field', field)"
>
<option value="">Sélectionner...</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
<input
:value="field.value ?? ''"
type="checkbox"
class="checkbox checkbox-sm"
:checked="String(field.value).toLowerCase() === 'true'"
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
@blur="$emit('update-custom-field', field)"
/>
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
</div>
<input
v-else-if="field.type === 'date'"
:value="field.value ?? ''"
type="date"
class="input input-bordered input-sm"
:required="field.required"
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
@blur="$emit('update-custom-field', field)"
/>
<div v-else class="text-xs text-error">
Type de champ non pris en charge
</div>
</template>
<template v-else>
<div class="input input-bordered input-sm bg-base-200">
{{ formatCustomFieldValue(field) }}
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import {
formatConstructeurContact as formatConstructeurContactSummary,
} from '~/shared/constructeurUtils'
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
defineProps<{
isEditMode: boolean
machineName: string
machineReference: string
machineConstructeurIds: string[]
machineConstructeursDisplay: any[]
hasMachineConstructeur: boolean
visibleCustomFields: any[]
getMachineFieldId: (fieldName: string) => string
}>()
defineEmits<{
'update:machine-name': [value: string]
'update:machine-reference': [value: string]
'update:constructeur-ids': [ids: unknown]
'blur-field': []
'set-custom-field-value': [field: any, value: unknown]
'update-custom-field': [field: any]
}>()
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Pièces de la machine</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<PieceItem
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>
</template>
<script setup lang="ts">
defineProps<{
pieces: any[]
isEditMode: boolean
}>()
defineEmits<{
'update-piece': [piece: any]
'edit-piece': [piece: any]
'custom-field-update': [fieldUpdate: any]
}>()
</script>

View File

@@ -0,0 +1,62 @@
<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">Produits associés</h2>
<p class="text-xs text-gray-500">
Produits sélectionnés directement pour cette machine selon le squelette.
</p>
</div>
<span class="badge badge-outline" v-if="products.length">
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
</span>
</div>
<div v-if="products.length" class="space-y-3">
<div
v-for="product in products"
:key="product.id || product.name"
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-1"
>
<div class="flex items-center justify-between flex-wrap gap-2">
<p class="font-semibold text-base-content">
{{ product.name }}
</p>
<span class="badge badge-ghost badge-sm">
{{ product.groupLabel }}
</span>
</div>
<p v-if="product.reference" class="text-xs text-base-content/70">
<span class="font-medium">Référence :</span>
<span class="ml-1">{{ product.reference }}</span>
</p>
<p v-if="product.supplierLabel" class="text-xs text-base-content/70">
<span class="font-medium">Fournisseurs :</span>
<span class="ml-1">{{ product.supplierLabel }}</span>
</p>
<p v-if="product.priceLabel" class="text-xs text-base-content/70">
<span class="font-medium">Prix indicatif :</span>
<span class="ml-1">{{ product.priceLabel }}</span>
</p>
</div>
</div>
<p v-else class="text-xs text-gray-500">
Aucun produit n'a été associé directement à cette machine.
</p>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
products: Array<{
id?: string | null
name?: string
reference?: string | null
supplierLabel?: string | null
priceLabel?: string | null
groupLabel?: string
}>
}>()
</script>

View File

@@ -0,0 +1,193 @@
<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

@@ -0,0 +1,205 @@
<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

@@ -0,0 +1,59 @@
<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

@@ -0,0 +1,126 @@
<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

@@ -0,0 +1,130 @@
<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

@@ -0,0 +1,142 @@
<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

@@ -29,10 +29,65 @@
:total="total" :total="total"
:limit="limit" :limit="limit"
:offset="offset" :offset="offset"
@related="openRelatedModal"
@edit="openEditPage" @edit="openEditPage"
@delete="confirmDelete" @delete="confirmDelete"
@update:offset="onOffsetChange" @update:offset="onOffsetChange"
/> />
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
<div class="modal-box max-w-3xl">
<h3 class="text-lg font-bold text-base-content">
{{ 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">
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Chargement des éléments liés
</div>
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
{{ relatedError }}
</div>
<div
v-else-if="relatedItems.length === 0"
class="px-4 py-6 text-sm text-base-content/60"
>
Aucun élément lié à cette catégorie.
</div>
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
<li
v-for="entry in relatedItems"
:key="entry.id"
class="px-2 py-1"
>
<button
type="button"
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
@click="openRelatedEdit(entry)"
>
<span class="font-medium text-base-content">{{ entry.name }}</span>
<span v-if="entry.reference" class="text-xs text-base-content/60">
Référence: {{ entry.reference }}
</span>
</button>
</li>
</ul>
</div>
<div class="modal-action">
<button type="button" class="btn" @click="closeRelatedModal">
Fermer
</button>
</div>
</div>
</dialog>
</main> </main>
</template> </template>
@@ -41,6 +96,8 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useHead, useRouter } from "#imports"; import { useHead, useRouter } from "#imports";
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue"; import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
import ModelTypesTable from "~/components/model-types/Table.vue"; import ModelTypesTable from "~/components/model-types/Table.vue";
import { useApi } from "~/composables/useApi";
import { extractCollection } from "~/shared/utils/apiHelpers";
import { import {
deleteModelType, deleteModelType,
listModelTypes, listModelTypes,
@@ -49,6 +106,7 @@ import {
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";
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.";
@@ -82,6 +140,7 @@ let activeController: AbortController | null = null;
const router = useRouter(); const router = useRouter();
const { showError, showSuccess } = useToast(); const { showError, showSuccess } = useToast();
const { get } = useApi();
const headingText = computed(() => props.heading); const headingText = computed(() => props.heading);
const descriptionText = computed( const descriptionText = computed(
@@ -96,7 +155,7 @@ useHead(() => ({
const extractErrorMessage = (error: unknown) => { const extractErrorMessage = (error: unknown) => {
if (error && typeof error === "object") { if (error && typeof error === "object") {
const maybeFetchError = error as { const maybeFetchError = error as {
data?: any; data?: Record<string, unknown>;
statusMessage?: string; statusMessage?: string;
message?: string; message?: string;
}; };
@@ -151,8 +210,8 @@ const refresh = async ({
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: any) { } catch (error: unknown) {
if (error?.name === "AbortError") { if (error && typeof error === "object" && (error as { name?: string }).name === "AbortError") {
return; return;
} }
showError(extractErrorMessage(error)); showError(extractErrorMessage(error));
@@ -235,16 +294,19 @@ const openEditPage = (item: ModelType) => {
}); });
}; };
const { confirm } = useConfirm()
const confirmDelete = async (item: ModelType) => { const confirmDelete = async (item: ModelType) => {
const confirmed = window.confirm( const confirmed = await confirm({
"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);
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) {
@@ -257,6 +319,153 @@ const confirmDelete = async (item: ModelType) => {
} }
}; };
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 current = relatedType.value;
if (!current) {
return "";
}
const labels =
relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT;
const count = relatedItems.value.length;
if (relatedLoading.value) {
return `Chargement des ${labels.plural}`;
}
if (count === 0) {
return `Aucun ${labels.singular} lié.`;
}
if (count === 1) {
return `1 ${labels.singular} lié.`;
}
return `${count} ${labels.plural} liés.`;
});
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
const resolveRelatedConfig = (category: ModelCategory) => {
if (category === "COMPONENT") {
return { endpoint: "/composants", filterKey: "typeComposant" };
}
if (category === "PIECE") {
return { endpoint: "/pieces", filterKey: "typePiece" };
}
return { endpoint: "/products", filterKey: "typeProduct" };
};
const resolveRelatedEditBasePath = (category: ModelCategory) => {
if (category === "COMPONENT") {
return "/component";
}
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) => {
relatedType.value = item;
relatedModalOpen.value = true;
void loadRelatedItems(item);
};
const openRelatedEdit = (entry: RelatedEntry) => {
const current = relatedType.value;
if (!current) {
return;
}
const basePath = resolveRelatedEditBasePath(current.category);
relatedModalOpen.value = false;
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
showError("Navigation impossible vers la fiche d'édition.");
});
};
const closeRelatedModal = () => {
relatedModalOpen.value = false;
};
watch( watch(
() => searchInput.value, () => searchInput.value,
(value) => { (value) => {

View File

@@ -15,6 +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>
@@ -47,6 +48,7 @@
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>
@@ -81,6 +83,7 @@
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>
@@ -92,7 +95,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" /> <PieceModelStructureEditor v-model="pieceStructure" :restricted-mode="restrictedMode" />
</div> </div>
<div <div
@@ -103,11 +106,30 @@
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" /> <PieceModelStructureEditor v-model="productStructure" :restricted-mode="restrictedMode" />
</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
@@ -150,6 +172,10 @@ const props = withDefaults(defineProps<{
structureLoading?: boolean structureLoading?: boolean
allowComponentSubcomponents?: boolean allowComponentSubcomponents?: boolean
componentSubcomponentMaxDepth?: number componentSubcomponentMaxDepth?: number
disableSubmit?: boolean
disableSubmitMessage?: string
restrictedMode?: boolean
restrictedModeMessage?: string
}>(), { }>(), {
initialData: null, initialData: null,
saving: false, saving: false,
@@ -157,6 +183,10 @@ const props = withDefaults(defineProps<{
structureLoading: false, structureLoading: false,
allowComponentSubcomponents: true, allowComponentSubcomponents: true,
componentSubcomponentMaxDepth: 1, componentSubcomponentMaxDepth: 1,
disableSubmit: false,
disableSubmitMessage: '',
restrictedMode: false,
restrictedModeMessage: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -173,6 +203,18 @@ const componentSubcomponentMaxDepth = computed(() =>
? props.componentSubcomponentMaxDepth ? props.componentSubcomponentMaxDepth
: 1, : 1,
) )
const disableSubmit = computed(() => props.disableSubmit === true)
const disableSubmitMessage = computed(() =>
(props.disableSubmitMessage && props.disableSubmitMessage.trim())
? props.disableSubmitMessage
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
)
const 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: '',
@@ -236,10 +278,11 @@ const resetForm = () => {
? incoming.code ? incoming.code
: generateCodeFromName(form.name) : generateCodeFromName(form.name)
form.category = incoming.category ?? props.initialCategory form.category = incoming.category ?? props.initialCategory
const incomingRecord = incoming as Record<string, unknown>
form.notes = typeof incoming.notes === 'string' form.notes = typeof incoming.notes === 'string'
? incoming.notes ? incoming.notes
: typeof (incoming as any).description === 'string' : typeof incomingRecord.description === 'string'
? (incoming as any).description ? incomingRecord.description
: '' : ''
errors.name = undefined errors.name = undefined
@@ -248,7 +291,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) const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
const validate = () => { const validate = () => {
errors.name = undefined errors.name = undefined

View File

@@ -34,7 +34,7 @@
<tr class="text-base-content/70"> <tr class="text-base-content/70">
<th scope="col">Nom</th> <th scope="col">Nom</th>
<th scope="col">Notes</th> <th scope="col">Notes</th>
<th scope="col" class="w-32 text-right">Actions</th> <th scope="col" class="w-48 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -45,6 +45,9 @@
<span v-else class="text-base-content/50"></span> <span v-else class="text-base-content/50"></span>
</td> </td>
<td class="text-right space-x-2"> <td class="text-right space-x-2">
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés
</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>
@@ -72,6 +75,9 @@
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p> <p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p> <p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
<footer class="mt-4 flex flex-wrap items-center gap-2 justify-end"> <footer class="mt-4 flex flex-wrap items-center gap-2 justify-end">
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
Liés
</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>
@@ -123,6 +129,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(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: 'update:offset', offset: number): void; (e: 'update:offset', offset: number): void;

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

@@ -1,105 +0,0 @@
import { useToast } from './useToast'
export function useApi () {
const { showSuccess, showError, showInfo } = useToast()
const { public: publicConfig } = useRuntimeConfig()
const API_BASE_URL = publicConfig.apiBaseUrl || 'http://localhost:3000'
const parsedApiTimeout = Number(publicConfig.apiTimeout ?? 30000)
const API_TIMEOUT = Number.isNaN(parsedApiTimeout) ? 30000 : parsedApiTimeout
const apiCall = async (endpoint, options = {}) => {
const url = `${API_BASE_URL}${endpoint}`
const defaultOptions = {
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
}
// Ajouter un timeout à la requête
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT)
try {
const response = await fetch(url, {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers
},
signal: controller.signal
})
clearTimeout(timeoutId)
if (response.ok) {
let data = null
if (response.status !== 204) {
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json') || contentType.includes('application/ld+json') || contentType.includes('+json')) {
const text = await response.text()
data = text ? JSON.parse(text) : null
} else {
const text = await response.text()
data = text || null
}
}
return { success: true, data }
} else {
const contentType = response.headers.get('content-type') || ''
let errorData = {}
if (contentType.includes('application/json')) {
errorData = await response.json().catch(() => ({}))
} else {
const text = await response.text().catch(() => '')
errorData = text ? { message: text } : {}
}
const errorMessage = errorData.message || `Erreur ${response.status}: ${response.statusText}`
showError(errorMessage)
return { success: false, error: errorMessage, status: response.status }
}
} catch (error) {
clearTimeout(timeoutId)
const errorMessage = error.name === 'AbortError' ? 'Timeout de la requête' : error.message || 'Erreur réseau'
showError(`Erreur réseau: ${errorMessage}`)
return { success: false, error: errorMessage }
}
}
const get = async (endpoint) => {
return apiCall(endpoint, { method: 'GET' })
}
const post = async (endpoint, data) => {
return apiCall(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json'
},
body: JSON.stringify(data)
})
}
const patch = async (endpoint, data) => {
return apiCall(endpoint, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json'
},
body: JSON.stringify(data)
})
}
const del = async (endpoint) => {
return apiCall(endpoint, { method: 'DELETE' })
}
return {
apiCall,
get,
post,
patch,
delete: del
}
}

128
app/composables/useApi.ts Normal file
View File

@@ -0,0 +1,128 @@
import { useToast } from './useToast'
export interface ApiResponse<T = any> {
success: boolean
data?: T
error?: string
status?: number
}
interface ApiCallOptions extends RequestInit {
headers?: Record<string, string>
}
export function useApi() {
const { showError } = useToast()
const { public: publicConfig } = useRuntimeConfig()
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || 'http://localhost:3000'
const parsedApiTimeout = Number(publicConfig.apiTimeout ?? 30000)
const API_TIMEOUT = Number.isNaN(parsedApiTimeout) ? 30000 : parsedApiTimeout
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
const url = `${API_BASE_URL}${endpoint}`
const defaultOptions: ApiCallOptions = {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
}
// Ajouter un timeout à la requête
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT)
try {
const response = await fetch(url, {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers,
},
signal: controller.signal,
})
clearTimeout(timeoutId)
if (response.ok) {
let data: T | null = null
if (response.status !== 204) {
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json') || contentType.includes('application/ld+json') || contentType.includes('+json')) {
const text = await response.text()
data = text ? JSON.parse(text) : null
} else {
const text = await response.text()
data = (text || null) as T | null
}
}
return { success: true, data: data as T }
} else {
const contentType = response.headers.get('content-type') || ''
let errorData: Record<string, unknown> = {}
if (contentType.includes('application/json')) {
errorData = await response.json().catch(() => ({}))
} else {
const text = await response.text().catch(() => '')
errorData = text ? { message: text } : {}
}
const errorMessage = (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
showError(errorMessage)
return { success: false, error: errorMessage, status: response.status }
}
} catch (error) {
clearTimeout(timeoutId)
const err = error as Error & { name?: string }
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau'
showError(`Erreur réseau: ${errorMessage}`)
return { success: false, error: errorMessage }
}
}
const get = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, { method: 'GET' })
}
const post = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json',
},
body: data !== undefined ? JSON.stringify(data) : undefined,
})
}
const patch = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: data !== undefined ? JSON.stringify(data) : undefined,
})
}
const put = async <T = any>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/ld+json',
},
body: data !== undefined ? JSON.stringify(data) : undefined,
})
}
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, { method: 'DELETE' })
}
return {
apiCall,
get,
post,
patch,
put,
delete: del,
}
}

View File

@@ -0,0 +1,114 @@
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,12 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ComponentHistoryActor = EntityHistoryActor
export type ComponentHistoryEntry = EntityHistoryEntry
export function useComponentHistory() {
return useEntityHistory('composant')
}

View File

@@ -1,47 +0,0 @@
import { ref, computed } from 'vue'
let hasWarned = false
const warnDeprecated = () => {
if (hasWarned) return
if (process.dev) {
console.warn('[useComponentModels] Ce composable est conservé pour compatibilité mais les modèles ont été remplacés par les catégories enrichies de squelette. Utilisez useComponentTypes / useComposants à la place.')
}
hasWarned = true
}
const buildUnsupportedResult = () => ({
success: false,
error: 'Les modèles de composants ont été retirés. Gérez les squelettes via les catégories et utilisez les requirements machine pour instancier des composants.'
})
export function useComponentModels () {
warnDeprecated()
const componentModelsBuckets = ref({})
const loadingComponentModels = ref(false)
const componentModels = computed(() => [])
const noLongerSupported = async () => {
warnDeprecated()
return buildUnsupportedResult()
}
const getComponentModels = () => componentModels.value
const getComponentModelsForType = () => []
const isComponentModelLoading = () => loadingComponentModels.value
return {
componentModels,
componentModelsBuckets,
loadingComponentModels,
loadComponentModels: noLongerSupported,
createComponentModel: noLongerSupported,
updateComponentModel: noLongerSupported,
deleteComponentModel: noLongerSupported,
getComponentModels,
getComponentModelsForType,
isComponentModelLoading
}
}

View File

@@ -1,140 +0,0 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
const componentTypes = ref([])
const loadingComponentTypes = ref(false)
export function useComponentTypes () {
const { showSuccess, showError } = useToast()
const generateCodeFromName = (name) => {
return (name || '')
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || 'type'
}
const loadComponentTypes = async () => {
loadingComponentTypes.value = true
try {
const data = await listModelTypes({
category: 'COMPONENT',
sort: 'name',
dir: 'asc',
limit: 200
})
componentTypes.value = data.items.map(item => ({
...item,
description: item.description ?? item.notes ?? null
}))
return { success: true, data: componentTypes.value }
} catch (error) {
const message = error?.message || 'Erreur inconnue'
showError(`Impossible de charger les types de composant: ${message}`)
return { success: false, error: message }
} finally {
loadingComponentTypes.value = false
}
}
const createComponentType = async (payload) => {
loadingComponentTypes.value = true
try {
const data = await createModelType({
name: payload.name,
code: payload.code || generateCodeFromName(payload.name),
category: 'COMPONENT',
notes: payload.description ?? payload.notes,
description: payload.description ?? null,
structure: payload.structure
})
const normalized = {
...data,
description: data.description ?? data.notes ?? null
}
componentTypes.value.push(normalized)
showSuccess(`Type de composant "${data.name}" créé`)
return { success: true, data: normalized }
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la création du type de composant: ${message}`)
return { success: false, error: message }
} finally {
loadingComponentTypes.value = false
}
}
const updateComponentType = async (id, payload) => {
loadingComponentTypes.value = true
try {
const data = await updateModelType(id, {
name: payload.name,
description: payload.description,
notes: payload.notes,
code: payload.code,
structure: payload.structure
})
const normalized = {
...data,
description: data.description ?? data.notes ?? null
}
const index = componentTypes.value.findIndex(type => type.id === id)
if (index !== -1) {
componentTypes.value[index] = normalized
}
showSuccess(`Type de composant "${data.name}" mis à jour`)
return {
success: true,
data: normalized
}
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la mise à jour du type de composant: ${message}`)
return { success: false, error: message }
} finally {
loadingComponentTypes.value = false
}
}
const deleteComponentType = async (id) => {
loadingComponentTypes.value = true
try {
await deleteModelType(id)
componentTypes.value = componentTypes.value.filter(type => type.id !== id)
showSuccess('Type de composant supprimé')
return { success: true }
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la suppression du type de composant: ${message}`)
return { success: false, error: message }
} finally {
loadingComponentTypes.value = false
}
}
const getComponentTypes = () => componentTypes.value
const isComponentTypeLoading = () => loadingComponentTypes.value
return {
componentTypes,
loadingComponentTypes,
loadComponentTypes,
createComponentType,
updateComponentType,
deleteComponentType,
getComponentTypes,
isComponentTypeLoading
}
}

View File

@@ -0,0 +1,29 @@
/**
* Backward-compatible wrapper around useEntityTypes.
* Preserves the original API surface (renamed fields) so consumers need no changes.
*/
import { useEntityTypes, type EntityType } from './useEntityTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { Ref } from 'vue'
export interface ComponentType extends EntityType {
structure: ComponentModelStructure | null
}
export function useComponentTypes() {
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
category: 'COMPONENT',
label: 'composant',
})
return {
componentTypes: types as Ref<ComponentType[]>,
loadingComponentTypes: loading,
loadComponentTypes: loadTypes,
createComponentType: createType,
updateComponentType: updateType,
deleteComponentType: deleteType,
getComponentTypes: () => types.value as ComponentType[],
isComponentTypeLoading: () => loading.value,
}
}

View File

@@ -2,45 +2,69 @@ import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
const composants = ref([]) export interface Composant {
const total = ref(0) id: string
const loading = ref(false) name: string
reference?: string | null
const extractCollection = (payload) => { typeComposantId?: string | null
if (Array.isArray(payload)) { typeComposant?: { id: string; name?: string } | null
return payload productId?: string | null
} product?: { id: string; name?: string } | null
if (Array.isArray(payload?.member)) { constructeurs?: Constructeur[]
return payload.member constructeurIds?: string[]
} documents?: unknown[]
if (Array.isArray(payload?.['hydra:member'])) { createdAt?: string | null
return payload['hydra:member'] updatedAt?: string | null
} [key: string]: unknown
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
} }
const extractTotal = (payload, fallbackLength) => { interface ComposantListResult {
if (typeof payload?.totalItems === 'number') { success: boolean
return payload.totalItems data?: { items: Composant[]; total: number; page: number; itemsPerPage: number }
error?: string
}
interface ComposantSingleResult {
success: boolean
data?: Composant
error?: string
}
interface LoadComposantsOptions {
search?: string
page?: number
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
force?: boolean
}
const composants = ref<Composant[]>([])
const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
} }
if (typeof payload?.['hydra:totalItems'] === 'number') { if (typeof p?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems'] return p['hydra:totalItems']
} }
return fallbackLength return fallbackLength
} }
export function useComposants () { export function useComposants() {
const { showSuccess, showError, showInfo } = useToast() const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
const withResolvedConstructeurs = async (composant) => { const withResolvedConstructeurs = async (composant: Composant): Promise<Composant> => {
if (!composant || typeof composant !== 'object') { if (!composant || typeof composant !== 'object') {
return composant return composant
} }
@@ -59,12 +83,11 @@ export function useComposants () {
const ids = uniqueConstructeurIds( const ids = uniqueConstructeurIds(
composant.constructeurIds, composant.constructeurIds,
composant.constructeurs, composant.constructeurs,
composant.constructeur,
) )
const hasResolvedConstructeurs = const hasResolvedConstructeurs =
Array.isArray(composant.constructeurs) Array.isArray(composant.constructeurs) &&
&& composant.constructeurs.length > 0 composant.constructeurs.length > 0 &&
&& composant.constructeurs.every((item) => item && typeof item === 'object') composant.constructeurs.every((item) => item && typeof item === 'object')
if (ids.length && !hasResolvedConstructeurs) { if (ids.length && !hasResolvedConstructeurs) {
const resolved = await ensureConstructeurs(ids) const resolved = await ensureConstructeurs(ids)
@@ -76,25 +99,32 @@ export function useComposants () {
return composant return composant
} }
/** const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
* Load composants with pagination and search support const {
* @param {Object} options - Query options search = '',
* @param {string} [options.search] - Search term for name/reference page = 1,
* @param {number} [options.page=1] - Current page (1-based) itemsPerPage = 30,
* @param {number} [options.itemsPerPage=30] - Items per page orderBy = 'name',
* @param {string} [options.orderBy='name'] - Field to order by orderDir = 'asc',
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc) force = false,
*/ } = options
const loadComposants = async (options = {}) => {
if (!force && loaded.value && !search && 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))
@@ -112,85 +142,91 @@ 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: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: total.value,
page, page,
itemsPerPage itemsPerPage,
} },
} }
} }
return result return result as ComposantListResult
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des composants:', error) console.error('Erreur lors du chargement des composants:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const createComposant = async (composantData) => { const createComposant = async (composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData)) const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
const result = await post('/composants', normalizedPayload) const result = await post('/composants', normalizedPayload)
if (result.success) { if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data) const enriched = await withResolvedConstructeurs(result.data as Composant)
composants.value.unshift(enriched) composants.value.unshift(enriched)
total.value += 1 total.value += 1
const displayName = result.data?.name const definition = (composantData as Record<string, unknown>)?.definition as Record<string, unknown> | undefined
|| composantData?.definition?.name const displayName =
|| composantData?.name (result.data as Composant)?.name ||
|| 'Composant' (definition?.name as string | undefined) ||
composantData?.name ||
'Composant'
showSuccess(`Composant "${displayName}" créé avec succès`) showSuccess(`Composant "${displayName}" créé avec succès`)
return { success: true, data: enriched }
} }
return result return { success: false, error: result.error }
} catch (error) { } catch (error) {
console.error('Erreur lors de la création du composant:', error) console.error('Erreur lors de la création du composant:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const updateComposantData = async (id, composantData) => { const updateComposantData = async (id: string, composantData: Partial<Composant>): Promise<ComposantSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData)) const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(composantData))
const result = await patch(`/composants/${id}`, normalizedPayload) const result = await patch(`/composants/${id}`, normalizedPayload)
if (result.success) { if (result.success && result.data) {
const updated = await withResolvedConstructeurs(result.data) const updated = await withResolvedConstructeurs(result.data as Composant)
const index = composants.value.findIndex(comp => comp.id === id) const index = composants.value.findIndex((comp) => comp.id === id)
if (index !== -1) { if (index !== -1) {
composants.value[index] = updated composants.value[index] = updated
} }
showSuccess(`Composant "${updated?.name || composantData.name || ''}" mis à jour avec succès`) showSuccess(`Composant "${updated?.name || composantData.name || ''}" mis à jour avec succès`)
return { success: true, data: updated }
} }
return result return { success: false, error: result.error }
} catch (error) { } catch (error) {
console.error('Erreur lors de la mise à jour du composant:', error) console.error('Erreur lors de la mise à jour du composant:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const deleteComposant = async (id) => { const deleteComposant = async (id: string): Promise<ComposantSingleResult> => {
loading.value = true loading.value = true
try { try {
const result = await del(`/composants/${id}`) const result = await del(`/composants/${id}`)
if (result.success) { if (result.success) {
const deletedComposant = composants.value.find(comp => comp.id === id) const deletedComposant = composants.value.find((comp) => comp.id === id)
composants.value = composants.value.filter(comp => comp.id !== id) composants.value = composants.value.filter((comp) => comp.id !== id)
total.value = Math.max(0, total.value - 1) total.value = Math.max(0, total.value - 1)
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`) showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
return { success: true }
} }
return result return { success: false, error: result.error }
} catch (error) { } catch (error) {
console.error('Erreur lors de la suppression du composant:', error) console.error('Erreur lors de la suppression du composant:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -199,15 +235,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,73 @@
/**
* Promise-based confirmation dialog composable.
*
* Usage:
* const { confirm, confirmState } = useConfirm()
* const ok = await confirm({ message: 'Supprimer ?' })
* if (ok) { ... }
*
* The ConfirmModal component reads `confirmState` to render the dialog.
*/
import { reactive } from 'vue'
export interface ConfirmOptions {
title?: string
message: string
confirmText?: string
cancelText?: string
dangerous?: boolean
}
export interface ConfirmState {
open: boolean
title: string
message: string
confirmText: string
cancelText: string
dangerous: boolean
resolve: ((value: boolean) => void) | null
}
const state = reactive<ConfirmState>({
open: false,
title: '',
message: '',
confirmText: 'Supprimer',
cancelText: 'Annuler',
dangerous: true,
resolve: null,
})
function confirm(options: ConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
state.title = options.title ?? 'Confirmation'
state.message = options.message
state.confirmText = options.confirmText ?? 'Supprimer'
state.cancelText = options.cancelText ?? 'Annuler'
state.dangerous = options.dangerous ?? true
state.resolve = resolve
state.open = true
})
}
function handleConfirm() {
state.resolve?.(true)
state.open = false
state.resolve = null
}
function handleCancel() {
state.resolve?.(false)
state.open = false
state.resolve = null
}
export function useConfirm() {
return {
confirm,
confirmState: state,
handleConfirm,
handleCancel,
}
}

View File

@@ -1,12 +1,27 @@
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 { extractCollection } from '~/shared/utils/apiHelpers'
const constructeurs = ref([]) export interface Constructeur {
id: string
name: string
email?: string | null
phone?: string | null
}
interface ConstructeurResult {
success: boolean
data?: Constructeur | Constructeur[]
error?: string
}
const constructeurs = ref<Constructeur[]>([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false)
const uniqueConstructeurs = (items = []) => { const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => {
const map = new Map() const map = new Map<string, Constructeur>()
items.forEach((item) => { items.forEach((item) => {
if (item && typeof item === 'object' && typeof item.id === 'string') { if (item && typeof item === 'object' && typeof item.id === 'string') {
map.set(item.id, item) map.set(item.id, item)
@@ -15,7 +30,7 @@ const uniqueConstructeurs = (items = []) => {
return Array.from(map.values()) return Array.from(map.values())
} }
const normalizeIds = (ids = []) => { const normalizeIds = (ids: unknown[] = []): string[] => {
if (!Array.isArray(ids)) { if (!Array.isArray(ids)) {
return [] return []
} }
@@ -28,7 +43,7 @@ const normalizeIds = (ids = []) => {
) )
} }
const upsertConstructeurs = (items = []) => { const upsertConstructeurs = (items: Constructeur[] = []) => {
if (!Array.isArray(items) || !items.length) { if (!Array.isArray(items) || !items.length) {
return return
} }
@@ -36,32 +51,19 @@ const upsertConstructeurs = (items = []) => {
constructeurs.value = merged constructeurs.value = merged
} }
const getIndexedConstructeur = (id) => const getIndexedConstructeur = (id: string): Constructeur | null =>
constructeurs.value.find((item) => item && item.id === id) || null constructeurs.value.find((item) => item && item.id === id) || null
const extractCollection = (payload) => { const pendingFetches = new Map<string, Promise<Constructeur | null>>()
if (Array.isArray(payload)) {
return payload
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
}
const pendingFetches = new Map() export function useConstructeurs() {
export function useConstructeurs () {
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
const { showSuccess, showError } = useToast() const { showSuccess, showError } = useToast()
const loadConstructeurs = async (search = '') => { const loadConstructeurs = async (search = '', options: { force?: boolean } = {}): Promise<ConstructeurResult> => {
if (!search && !options.force && loaded.value) {
return { success: true, data: constructeurs.value }
}
loading.value = true loading.value = true
try { try {
const query = search ? `?search=${encodeURIComponent(search)}` : '' const query = search ? `?search=${encodeURIComponent(search)}` : ''
@@ -69,48 +71,51 @@ export function useConstructeurs () {
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items) constructeurs.value = uniqueConstructeurs(items)
if (!search) loaded.value = true
} }
return result return result as ConstructeurResult
} catch (error) { } catch (error) {
const err = error as Error
console.error('Erreur lors du chargement des fournisseurs:', error) console.error('Erreur lors du chargement des fournisseurs:', error)
return { success: false, error: error.message } return { success: false, error: err.message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const searchConstructeurs = async (search = '') => { const searchConstructeurs = async (search = ''): Promise<ConstructeurResult> => {
return loadConstructeurs(search) return loadConstructeurs(search)
} }
const createConstructeur = async (data) => { const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => {
loading.value = true loading.value = true
try { try {
const result = await post('/constructeurs', data) const result = await post('/constructeurs', data)
if (result.success) { if (result.success) {
upsertConstructeurs([result.data]) upsertConstructeurs([result.data as Constructeur])
showSuccess(`Fournisseur "${result.data.name}" créé`) showSuccess(`Fournisseur "${(result.data as Constructeur).name}" créé`)
} else if (result.error) { } else if (result.error) {
showError(result.error) showError(result.error)
} }
return result return result as ConstructeurResult
} catch (error) { } catch (error) {
const err = error as Error
console.error('Erreur lors de la création du fournisseur:', error) console.error('Erreur lors de la création du fournisseur:', error)
showError('Impossible de créer le fournisseur') showError('Impossible de créer le fournisseur')
return { success: false, error: error.message } return { success: false, error: err.message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const ensureConstructeurs = async (ids = []) => { const ensureConstructeurs = async (ids: unknown[] = []): Promise<Constructeur[]> => {
const normalizedIds = normalizeIds(ids) const normalizedIds = normalizeIds(ids)
if (!normalizedIds.length) { if (!normalizedIds.length) {
return [] return []
} }
const collected = [] const collected: Constructeur[] = []
const missing = [] const missing: string[] = []
normalizedIds.forEach((id) => { normalizedIds.forEach((id) => {
const existing = getIndexedConstructeur(id) const existing = getIndexedConstructeur(id)
if (existing) { if (existing) {
@@ -129,7 +134,7 @@ export function useConstructeurs () {
const task = get(`/constructeurs/${id}`) const task = get(`/constructeurs/${id}`)
.then((result) => { .then((result) => {
if (result.success && result.data) { if (result.success && result.data) {
return result.data return result.data as Constructeur
} }
return null return null
}) })
@@ -145,7 +150,7 @@ export function useConstructeurs () {
}) })
const fetched = await Promise.all(fetchTasks) const fetched = await Promise.all(fetchTasks)
const validFetched = fetched.filter((item) => item && item.id) const validFetched = fetched.filter((item): item is Constructeur => item !== null && item.id !== undefined)
if (validFetched.length) { if (validFetched.length) {
upsertConstructeurs(validFetched) upsertConstructeurs(validFetched)
} }
@@ -153,50 +158,52 @@ export function useConstructeurs () {
return normalizedIds return normalizedIds
.map((id) => getIndexedConstructeur(id)) .map((id) => getIndexedConstructeur(id))
.filter((item) => Boolean(item)) .filter((item): item is Constructeur => item !== null)
} }
const updateConstructeur = async (id, data) => { const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => {
loading.value = true loading.value = true
try { try {
const result = await patch(`/constructeurs/${id}`, data) const result = await patch(`/constructeurs/${id}`, data)
if (result.success) { if (result.success) {
upsertConstructeurs([result.data]) upsertConstructeurs([result.data as Constructeur])
showSuccess(`Fournisseur "${result.data.name}" mis à jour`) showSuccess(`Fournisseur "${(result.data as Constructeur).name}" mis à jour`)
} else if (result.error) { } else if (result.error) {
showError(result.error) showError(result.error)
} }
return result return result as ConstructeurResult
} catch (error) { } catch (error) {
const err = error as Error
console.error('Erreur lors de la mise à jour du fournisseur:', error) console.error('Erreur lors de la mise à jour du fournisseur:', error)
showError('Impossible de mettre à jour le fournisseur') showError('Impossible de mettre à jour le fournisseur')
return { success: false, error: error.message } return { success: false, error: err.message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const deleteConstructeur = async (id) => { const deleteConstructeur = async (id: string): Promise<ConstructeurResult> => {
loading.value = true loading.value = true
try { try {
const result = await del(`/constructeurs/${id}`) const result = await del(`/constructeurs/${id}`)
if (result.success) { if (result.success) {
constructeurs.value = constructeurs.value.filter(item => item.id !== id) constructeurs.value = constructeurs.value.filter((item) => item.id !== id)
showSuccess('Fournisseur supprimé') showSuccess('Fournisseur supprimé')
} else if (result.error) { } else if (result.error) {
showError(result.error) showError(result.error)
} }
return result return result as ConstructeurResult
} catch (error) { } catch (error) {
const err = error as Error
console.error('Erreur lors de la suppression du fournisseur:', error) console.error('Erreur lors de la suppression du fournisseur:', error)
showError('Impossible de supprimer le fournisseur') showError('Impossible de supprimer le fournisseur')
return { success: false, error: error.message } return { success: false, error: err.message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const getConstructeurById = (id) => getIndexedConstructeur(id) const getConstructeurById = (id: string) => getIndexedConstructeur(id)
return { return {
constructeurs, constructeurs,

View File

@@ -1,66 +1,75 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from './useApi' import { useApi, type ApiResponse } from './useApi'
export function useCustomFields () { export interface CustomFieldValue {
id: string
customFieldId: string
entityType: string
entityId: string
value: unknown
[key: string]: unknown
}
export function useCustomFields() {
const { apiCall } = useApi() const { apiCall } = useApi()
const customFieldValues = ref([]) const customFieldValues = ref<CustomFieldValue[]>([])
const loading = ref(false) const loading = ref(false)
// Créer une valeur de champ personnalisé // Créer une valeur de champ personnalisé
const createCustomFieldValue = async (customFieldValueData) => { const createCustomFieldValue = async (customFieldValueData: Record<string, unknown>): Promise<ApiResponse> => {
try { try {
const result = await apiCall('/custom-fields/values', { const result = await apiCall('/custom-fields/values', {
method: 'POST', method: 'POST',
body: JSON.stringify(customFieldValueData) body: JSON.stringify(customFieldValueData),
}) })
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la création de la valeur de champ personnalisé:', error) console.error('Erreur lors de la création de la valeur de champ personnalisé:', error)
return { success: false, error } return { success: false, error: (error as Error).message }
} }
} }
// Obtenir les valeurs de champs personnalisés pour une entité // Obtenir les valeurs de champs personnalisés pour une entité
const getCustomFieldValuesByEntity = async (entityType, entityId) => { const getCustomFieldValuesByEntity = async (entityType: string, entityId: string): Promise<ApiResponse> => {
try { try {
loading.value = true loading.value = true
const result = await apiCall(`/custom-fields/values/${entityType}/${entityId}`, { const result = await apiCall(`/custom-fields/values/${entityType}/${entityId}`, {
method: 'GET' method: 'GET',
}) })
if (result.success) { if (result.success) {
customFieldValues.value = result.data customFieldValues.value = result.data as CustomFieldValue[]
} }
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la récupération des valeurs de champs personnalisés:', error) console.error('Erreur lors de la récupération des valeurs de champs personnalisés:', error)
return { success: false, error } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// Mettre à jour une valeur de champ personnalisé // Mettre à jour une valeur de champ personnalisé
const updateCustomFieldValue = async (id, updateData) => { const updateCustomFieldValue = async (id: string, updateData: Record<string, unknown>): Promise<ApiResponse> => {
try { try {
const result = await apiCall(`/custom-fields/values/${id}`, { const result = await apiCall(`/custom-fields/values/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(updateData) body: JSON.stringify(updateData),
}) })
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la mise à jour de la valeur de champ personnalisé:', error) console.error('Erreur lors de la mise à jour de la valeur de champ personnalisé:', error)
return { success: false, error } return { success: false, error: (error as Error).message }
} }
} }
// Créer ou mettre à jour une valeur de champ personnalisé // Créer ou mettre à jour une valeur de champ personnalisé
const upsertCustomFieldValue = async ( const upsertCustomFieldValue = async (
customFieldId, customFieldId: string | null,
entityType, entityType: string,
entityId, entityId: string,
value, value: unknown,
metadata = {}, metadata: Record<string, unknown> = {},
) => { ): Promise<ApiResponse> => {
try { try {
const result = await apiCall('/custom-fields/values/upsert', { const result = await apiCall('/custom-fields/values/upsert', {
method: 'POST', method: 'POST',
@@ -69,26 +78,26 @@ export function useCustomFields () {
entityType, entityType,
entityId, entityId,
value, value,
...metadata ...metadata,
}) }),
}) })
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la création/mise à jour de la valeur de champ personnalisé:', error) console.error('Erreur lors de la création/mise à jour de la valeur de champ personnalisé:', error)
return { success: false, error } return { success: false, error: (error as Error).message }
} }
} }
// Supprimer une valeur de champ personnalisé // Supprimer une valeur de champ personnalisé
const deleteCustomFieldValue = async (id) => { const deleteCustomFieldValue = async (id: string): Promise<ApiResponse> => {
try { try {
const result = await apiCall(`/custom-fields/values/${id}`, { const result = await apiCall(`/custom-fields/values/${id}`, {
method: 'DELETE' method: 'DELETE',
}) })
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la suppression de la valeur de champ personnalisé:', error) console.error('Erreur lors de la suppression de la valeur de champ personnalisé:', error)
return { success: false, error } return { success: false, error: (error as Error).message }
} }
} }
@@ -99,6 +108,6 @@ export function useCustomFields () {
getCustomFieldValuesByEntity, getCustomFieldValuesByEntity,
updateCustomFieldValue, updateCustomFieldValue,
upsertCustomFieldValue, upsertCustomFieldValue,
deleteCustomFieldValue deleteCustomFieldValue,
} }
} }

View File

@@ -2,39 +2,55 @@ 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 { normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
const documents = ref([]) export interface Document {
const loading = ref(false) id: string
name: string
const extractCollection = (payload) => { filename: string
if (Array.isArray(payload)) { mimeType: string
return payload size: number
} path: string
if (Array.isArray(payload?.member)) { siteId?: string
return payload.member machineId?: string
} composantId?: string
if (Array.isArray(payload?.['hydra:member'])) { productId?: string
return payload['hydra:member'] pieceId?: string
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
} }
const fileToBase64 = file => export interface UploadContext {
siteId?: string
machineId?: string
composantId?: string
productId?: string
pieceId?: string
}
export interface DocumentResult {
success: boolean
data?: Document | Document[]
error?: string
}
const documents = ref<Document[]>([])
const loading = ref(false)
const fileToBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => resolve(reader.result) reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`)) reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
reader.readAsDataURL(file) reader.readAsDataURL(file)
}) })
export function useDocuments () { export function useDocuments() {
const { get, post, delete: del } = useApi() const { get, post, delete: del } = useApi()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const loadFromEndpoint = async (endpoint, { updateStore = false } = {}) => { const loadFromEndpoint = async (
endpoint: string,
{ updateStore = false }: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
loading.value = true loading.value = true
try { try {
const result = await get(endpoint) const result = await get(endpoint)
@@ -48,50 +64,83 @@ export function useDocuments () {
if (result.error) { if (result.error) {
showError(result.error) showError(result.error)
} }
return result return result as DocumentResult
} catch (error) { } catch (error) {
const err = error as Error
console.error(`Erreur lors du chargement des documents (${endpoint}):`, error) console.error(`Erreur lors du chargement des documents (${endpoint}):`, error)
showError('Impossible de charger les documents') showError('Impossible de charger les documents')
return { success: false, error: error.message } return { success: false, error: err.message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const loadDocuments = async (options = {}) => { const loadDocuments = async (
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true }) return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
} }
const loadDocumentsBySite = async (siteId, options = {}) => { const loadDocumentsBySite = async (
if (!siteId) { return { success: false, error: 'Aucun site sélectionné' } } siteId: string,
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!siteId) {
return { success: false, error: 'Aucun site sélectionné' }
}
return loadFromEndpoint(`/documents/site/${siteId}`, { updateStore: options.updateStore ?? false }) return loadFromEndpoint(`/documents/site/${siteId}`, { updateStore: options.updateStore ?? false })
} }
const loadDocumentsByMachine = async (machineId, options = {}) => { const loadDocumentsByMachine = async (
if (!machineId) { return { success: false, error: 'Aucune machine sélectionnée' } } machineId: string,
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!machineId) {
return { success: false, error: 'Aucune machine sélectionnée' }
}
return loadFromEndpoint(`/documents/machine/${machineId}`, { updateStore: options.updateStore ?? false }) return loadFromEndpoint(`/documents/machine/${machineId}`, { updateStore: options.updateStore ?? false })
} }
const loadDocumentsByComponent = async (componentId, options = {}) => { const loadDocumentsByComponent = async (
if (!componentId) { return { success: false, error: 'Aucun composant sélectionné' } } componentId: string,
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!componentId) {
return { success: false, error: 'Aucun composant sélectionné' }
}
return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false }) return loadFromEndpoint(`/documents/composant/${componentId}`, { updateStore: options.updateStore ?? false })
} }
const loadDocumentsByProduct = async (productId, options = {}) => { const loadDocumentsByProduct = async (
if (!productId) { return { success: false, error: 'Aucun produit sélectionné' } } productId: string,
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!productId) {
return { success: false, error: 'Aucun produit sélectionné' }
}
return loadFromEndpoint(`/documents/product/${productId}`, { updateStore: options.updateStore ?? false }) return loadFromEndpoint(`/documents/product/${productId}`, { updateStore: options.updateStore ?? false })
} }
const loadDocumentsByPiece = async (pieceId, options = {}) => { const loadDocumentsByPiece = async (
if (!pieceId) { return { success: false, error: 'Aucune pièce sélectionnée' } } pieceId: string,
options: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!pieceId) {
return { success: false, error: 'Aucune pièce sélectionnée' }
}
return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false }) return loadFromEndpoint(`/documents/piece/${pieceId}`, { updateStore: options.updateStore ?? false })
} }
const uploadDocuments = async ({ files = [], context = {} }, { updateStore = false } = {}) => { const uploadDocuments = async (
if (!files.length) { return { success: false, error: 'Aucun fichier sélectionné' } } { files, context = {} }: { files: File[]; context?: UploadContext },
{ updateStore = false }: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!files.length) {
return { success: false, error: 'Aucun fichier sélectionné' }
}
loading.value = true loading.value = true
const created = [] const created: Document[] = []
try { try {
for (const file of files) { for (const file of files) {
@@ -103,12 +152,12 @@ export function useDocuments () {
mimeType: file.type || 'application/octet-stream', mimeType: file.type || 'application/octet-stream',
size: file.size, size: file.size,
path: dataUrl, path: dataUrl,
...context ...context,
}) })
const result = await post('/documents', payload) const result = await post('/documents', payload)
if (result.success) { if (result.success) {
created.push(result.data) created.push(result.data as Document)
showSuccess(`Document "${file.name}" ajouté`) showSuccess(`Document "${file.name}" ajouté`)
} else if (result.error) { } else if (result.error) {
showError(`Erreur sur ${file.name} : ${result.error}`) showError(`Erreur sur ${file.name} : ${result.error}`)
@@ -124,31 +173,38 @@ export function useDocuments () {
return { success: false, error: 'Aucun document ajouté' } return { success: false, error: 'Aucun document ajouté' }
} catch (error) { } catch (error) {
console.error('Erreur lors de l\'upload des documents:', error) const err = error as Error
console.error("Erreur lors de l'upload des documents:", error)
showError("Échec de l'ajout des documents") showError("Échec de l'ajout des documents")
return { success: false, error: error.message } return { success: false, error: err.message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const deleteDocument = async (id, { updateStore = false } = {}) => { const deleteDocument = async (
if (!id) { return { success: false, error: 'Identifiant manquant' } } id: string | number,
{ updateStore = false }: { updateStore?: boolean } = {},
): Promise<DocumentResult> => {
if (!id) {
return { success: false, error: 'Identifiant manquant' }
}
loading.value = true loading.value = true
try { try {
const result = await del(`/documents/${id}`) const result = await del(`/documents/${id}`)
if (result.success) { if (result.success) {
if (updateStore) { if (updateStore) {
documents.value = documents.value.filter(doc => doc.id !== id) documents.value = documents.value.filter((doc) => doc.id !== id)
} }
showSuccess('Document supprimé') showSuccess('Document supprimé')
} }
return result return result as DocumentResult
} catch (error) { } catch (error) {
const err = error as Error
console.error('Erreur lors de la suppression du document:', error) console.error('Erreur lors de la suppression du document:', error)
showError('Impossible de supprimer le document') showError('Impossible de supprimer le document')
return { success: false, error: error.message } return { success: false, error: err.message }
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -164,6 +220,6 @@ export function useDocuments () {
loadDocumentsByPiece, loadDocumentsByPiece,
loadDocumentsByProduct, loadDocumentsByProduct,
uploadDocuments, uploadDocuments,
deleteDocument deleteDocument,
} }
} }

View File

@@ -0,0 +1,181 @@
/**
* Reactive custom field management for entity items (ComponentItem, PieceItem).
*
* Wraps the pure logic from entityCustomFieldLogic.ts with Vue reactivity,
* watchers, and API calls for updating/upserting custom field values.
*/
import { computed, watch } from 'vue'
import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast'
import {
buildDefinitionSources,
buildCandidateCustomFields,
mergeFieldDefinitionsWithValues,
dedupeMergedFields,
ensureCustomFieldId,
resolveFieldId,
resolveFieldName,
resolveFieldType,
resolveFieldReadOnly,
resolveCustomFieldId,
buildCustomFieldMetadata,
} from '~/shared/utils/entityCustomFieldLogic'
export interface EntityCustomFieldsDeps {
entity: () => any
entityType: 'composant' | 'piece'
}
export function useEntityCustomFields(deps: EntityCustomFieldsDeps) {
const { entity, entityType } = deps
const {
updateCustomFieldValue: updateCustomFieldValueApi,
upsertCustomFieldValue,
} = useCustomFields()
const { showSuccess, showError } = useToast()
const definitionSources = computed(() =>
buildDefinitionSources(entity(), entityType),
)
const displayedCustomFields = computed(() =>
dedupeMergedFields(
mergeFieldDefinitionsWithValues(
definitionSources.value,
entity().customFieldValues,
),
),
)
const candidateCustomFields = computed(() =>
buildCandidateCustomFields(entity(), definitionSources.value),
)
// Watchers to ensure field IDs are resolved
watch(
candidateCustomFields,
() => {
const candidates = candidateCustomFields.value
;(displayedCustomFields.value || []).forEach((field: any) => {
if (field) ensureCustomFieldId(field, candidates)
})
},
{ immediate: true, deep: true },
)
watch(
displayedCustomFields,
(fields) => {
const candidates = candidateCustomFields.value
;(fields || []).forEach((field: any) => {
if (field) ensureCustomFieldId(field, candidates)
})
},
{ immediate: true, deep: true },
)
const updateCustomField = async (field: any) => {
if (!field || resolveFieldReadOnly(field)) return
const e = entity()
const fieldValueId = resolveFieldId(field)
// Update existing field value
if (fieldValueId) {
const result: any = await updateCustomFieldValueApi(fieldValueId, { value: field.value ?? '' })
if (result.success) {
const existingValue = e.customFieldValues?.find((v: any) => v.id === fieldValueId)
if (existingValue?.customField?.id) {
field.customFieldId = existingValue.customField.id
field.customField = existingValue.customField
}
showSuccess(`Champ "${resolveFieldName(field)}" mis à jour avec succès`)
} else {
showError(`Erreur lors de la mise à jour du champ "${resolveFieldName(field)}"`)
}
return
}
// Create new field value
const customFieldId = ensureCustomFieldId(field, candidateCustomFields.value)
const fieldName = resolveFieldName(field)
if (!e?.id) {
showError(`Impossible de créer la valeur pour ce champ`)
return
}
if (!customFieldId && (!fieldName || fieldName === 'Champ')) {
showError(`Impossible de créer la valeur pour ce champ`)
return
}
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field)
const result: any = await upsertCustomFieldValue(
customFieldId,
entityType,
e.id,
field.value ?? '',
metadata,
)
if (result.success) {
const newValue = result.data
if (newValue?.id) {
field.customFieldValueId = newValue.id
field.value = newValue.value ?? field.value ?? ''
if (newValue.customField?.id) {
field.customFieldId = newValue.customField.id
field.customField = newValue.customField
}
if (Array.isArray(e.customFieldValues)) {
const index = e.customFieldValues.findIndex((v: any) => v.id === newValue.id)
if (index !== -1) {
e.customFieldValues.splice(index, 1, newValue)
} else {
e.customFieldValues.push(newValue)
}
} else {
e.customFieldValues = [newValue]
}
}
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`)
// Update definitions list
const definitions = Array.isArray(e.customFields) ? [...e.customFields] : []
const fieldIdentifier = ensureCustomFieldId(field, candidateCustomFields.value)
const existingIndex = definitions.findIndex((definition: any) => {
const definitionId = resolveCustomFieldId(definition)
if (fieldIdentifier && definitionId) return definitionId === fieldIdentifier
return definition?.name === resolveFieldName(field)
})
const updatedDefinition = {
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
customFieldValueId: field.customFieldValueId,
customFieldId: fieldIdentifier,
name: resolveFieldName(field),
type: resolveFieldType(field),
required: field.required ?? false,
options: field.options ?? [],
value: field.value ?? '',
customField: field.customField ?? null,
}
if (existingIndex !== -1) {
definitions.splice(existingIndex, 1, updatedDefinition)
} else {
definitions.push(updatedDefinition)
}
e.customFields = definitions
} else {
showError(`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`)
}
}
return {
displayedCustomFields,
candidateCustomFields,
updateCustomField,
}
}

View File

@@ -0,0 +1,122 @@
/**
* Reactive document management for entity items (ComponentItem, PieceItem).
*
* Handles document CRUD operations, preview modal state, and lazy loading.
* Display helpers (formatSize, shouldInlinePdf, etc.) are imported from
* shared/utils/documentDisplayUtils.ts.
*/
import { ref, computed, watch } from 'vue'
import { useDocuments } from '~/composables/useDocuments'
import { canPreviewDocument } from '~/utils/documentPreview'
export interface EntityDocumentsDeps {
entity: () => any
entityType: 'composant' | 'piece'
}
export function useEntityDocuments(deps: EntityDocumentsDeps) {
const { entity, entityType } = deps
const { uploadDocuments, deleteDocument } = useDocuments()
const loadDocumentsFn = entityType === 'composant'
? useDocuments().loadDocumentsByComponent
: useDocuments().loadDocumentsByPiece
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const documentsLoaded = ref(!!(entity().documents && entity().documents.length))
const documents = computed(() => entity().documents || [])
// Preview modal state
const previewDocument = ref<any>(null)
const previewVisible = ref(false)
const openPreview = (doc: any) => {
if (!canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => {
previewVisible.value = false
previewDocument.value = null
}
// Document watchers
watch(
() => entity().documents,
(docs: any) => {
documentsLoaded.value = !!(docs && docs.length)
},
)
// CRUD operations
const refreshDocuments = async () => {
const e = entity()
if (!e?.id) return
loadingDocuments.value = true
try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
if (result.success) {
e.documents = result.data || []
documentsLoaded.value = true
}
} finally {
loadingDocuments.value = false
}
}
const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !entity()?.id) return
await refreshDocuments()
}
const handleFilesAdded = async (files: File[]) => {
const e = entity()
if (!files.length || !e?.id) return
uploadingDocuments.value = true
try {
const contextKey = entityType === 'composant' ? 'composantId' : 'pieceId'
const result: any = await uploadDocuments(
{ files, context: { [contextKey]: e.id } } as any,
{ updateStore: false } as any,
)
if (result.success) {
const newDocs = result.data || []
e.documents = [...newDocs, ...(e.documents || [])]
documentsLoaded.value = true
selectedFiles.value = []
}
} finally {
uploadingDocuments.value = false
}
}
const removeDocument = async (documentId: string) => {
if (!documentId) return
const result: any = await deleteDocument(documentId, { updateStore: false } as any)
if (result.success) {
const e = entity()
e.documents = (e.documents || []).filter((doc: any) => doc.id !== documentId)
}
}
return {
documents,
selectedFiles,
uploadingDocuments,
loadingDocuments,
documentsLoaded,
previewDocument,
previewVisible,
openPreview,
closePreview,
refreshDocuments,
ensureDocumentsLoaded,
handleFilesAdded,
removeDocument,
}
}

View File

@@ -0,0 +1,69 @@
/**
* Generic entity history composable.
*
* Replaces useComponentHistory, usePieceHistory, useProductHistory which were
* 99% identical (only the API endpoint differed).
*/
import { ref } from 'vue'
import { useApi } from '~/composables/useApi'
export type EntityHistoryActor = {
id: string
label: string
}
export type EntityHistoryEntry = {
id: string
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: EntityHistoryActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
const ENTITY_ENDPOINTS: Record<string, string> = {
composant: '/composants',
piece: '/pieces',
product: '/products',
}
const extractItems = (payload: any): EntityHistoryEntry[] => {
if (Array.isArray(payload?.items)) return payload.items
if (Array.isArray(payload?.member)) return payload.member
if (Array.isArray(payload?.['hydra:member'])) return payload['hydra:member']
return []
}
export function useEntityHistory(entityType: 'composant' | 'piece' | 'product') {
const { get } = useApi()
const basePath = ENTITY_ENDPOINTS[entityType]
const history = ref<EntityHistoryEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const loadHistory = async (entityId: string) => {
loading.value = true
error.value = null
try {
const result = await get(`${basePath}/${entityId}/history`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger l\'historique.'
history.value = []
return result
}
history.value = extractItems(result.data)
return { success: true, data: history.value }
} catch (err: any) {
const message = err?.message ?? 'Erreur inconnue'
error.value = message
history.value = []
return { success: false, error: message }
} finally {
loading.value = false
}
}
return { history, loading, error, loadHistory }
}

View File

@@ -0,0 +1,103 @@
/**
* Reactive product display for entity items (ComponentItem, PieceItem).
*
* Resolves product information from entity.product, entity.__productDisplay,
* or a selectedProduct ref, and produces display-ready computed properties.
*/
import { computed, type Ref } from 'vue'
const currencyFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
function buildProductDisplay(product: any) {
if (!product || typeof product !== 'object') return null
const suppliers = Array.isArray(product.constructeurs)
? product.constructeurs
.map((c: any) => c?.name)
.filter((name: any) => typeof name === 'string' && name.trim().length > 0)
.join(', ')
: product.supplierLabel || null
const priceValue = product.supplierPrice ?? product.price ?? product.priceLabel ?? product.priceDisplay ?? null
let price: string | null = null
if (priceValue !== null && priceValue !== undefined) {
const parsed = Number(priceValue)
if (!Number.isNaN(parsed)) {
price = currencyFormatter.format(parsed)
} else if (typeof priceValue === 'string' && priceValue.trim().length > 0) {
price = priceValue
}
}
return {
name: product.name || product.label || product.reference || product.productName || null,
reference: product.reference || null,
category: product.typeProduct?.name || product.category || null,
suppliers,
price,
}
}
export interface EntityProductDisplayDeps {
entity: () => any
selectedProduct?: Ref<any>
}
export function useEntityProductDisplay(deps: EntityProductDisplayDeps) {
const { entity, selectedProduct } = deps
const displayProduct = computed(() => {
// Priority: selectedProduct (for PieceItem) → entity.product → entity.__productDisplay
if (selectedProduct?.value) {
const normalized = buildProductDisplay(selectedProduct.value)
if (normalized) return normalized
}
const explicit = entity().product || null
const normalized = buildProductDisplay(explicit)
if (normalized) return normalized
const fallback = entity().__productDisplay
if (fallback) {
return {
name: fallback.name || null,
reference: fallback.reference || null,
category: fallback.category || null,
suppliers: fallback.suppliers || null,
price: fallback.price || null,
}
}
return null
})
const displayProductName = computed(() => {
if (displayProduct.value?.name) return displayProduct.value.name
const e = entity()
return e.product?.name || e.productName || e.productLabel || null
})
const productInfoRows = computed(() => {
if (!displayProduct.value) return []
const rows: { label: string; value: string }[] = []
if (displayProduct.value.reference) rows.push({ label: 'Référence', value: displayProduct.value.reference })
if (displayProduct.value.price) rows.push({ label: 'Prix indicatif', value: displayProduct.value.price })
if (displayProduct.value.suppliers) rows.push({ label: 'Fournisseur(s)', value: displayProduct.value.suppliers })
if (displayProduct.value.category) rows.push({ label: 'Catégorie', value: displayProduct.value.category })
return rows
})
const productDocuments = computed(() => {
const product = selectedProduct?.value || entity().product || null
return Array.isArray(product?.documents) ? product.documents : []
})
return {
displayProduct,
displayProductName,
productInfoRows,
productDocuments,
}
}

View File

@@ -0,0 +1,188 @@
/**
* Generic entity types composable.
*
* Replaces useComponentTypes, usePieceTypes, useProductTypes which were
* 95%+ identical (only the category string and labels differed).
*/
import { ref, type Ref } from 'vue'
import { useToast } from './useToast'
import {
listModelTypes,
createModelType,
updateModelType,
deleteModelType,
type ModelType,
type ModelCategory,
} from '~/services/modelTypes'
export interface EntityType extends ModelType {
description?: string | null
}
interface EntityTypePayload {
name: string
code?: string
description?: string | null
notes?: string | null
structure?: any
}
interface EntityTypeResult {
success: boolean
data?: EntityType | EntityType[]
error?: string
}
interface EntityTypeConfig {
category: ModelCategory
label: string // e.g. 'composant', 'pièce', 'produit'
}
const generateCodeFromName = (name: string): string => {
return (name || '')
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || 'type'
}
// Shared state per category (module-level singletons)
const stateByCategory: Record<string, { types: Ref<EntityType[]>; loading: Ref<boolean>; loaded: Ref<boolean> }> = {}
function getOrCreateState(category: ModelCategory) {
if (!stateByCategory[category]) {
stateByCategory[category] = {
types: ref<EntityType[]>([]),
loading: ref(false),
loaded: ref(false),
}
}
return stateByCategory[category]
}
/**
* Marks the cache for a given category as stale.
* Next call to `loadTypes()` (without `force`) will refetch from the API.
* Safe to call from event handlers (no setup context required).
*/
export function invalidateEntityTypeCache(category: ModelCategory) {
const state = stateByCategory[category]
if (state) {
state.loaded.value = false
}
}
export function useEntityTypes(config: EntityTypeConfig) {
const { category, label } = config
const { showSuccess, showError } = useToast()
const state = getOrCreateState(category)
const normalizeItem = (item: ModelType): EntityType => ({
...item,
description: item.description ?? item.notes ?? null,
})
const loadTypes = async (options: { force?: boolean } = {}): Promise<EntityTypeResult> => {
if (!options.force && state.loaded.value) {
return { success: true, data: state.types.value }
}
state.loading.value = true
try {
const data = await listModelTypes({
category,
sort: 'name',
dir: 'asc',
limit: 200,
})
state.types.value = data.items.map(normalizeItem)
state.loaded.value = true
return { success: true, data: state.types.value }
} catch (error) {
const err = error as Error & { message?: string }
const message = err?.message || 'Erreur inconnue'
showError(`Impossible de charger les types de ${label}: ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
}
}
const createType = async (payload: EntityTypePayload): Promise<EntityTypeResult> => {
state.loading.value = true
try {
const data = await createModelType({
name: payload.name,
code: payload.code || generateCodeFromName(payload.name),
category,
notes: payload.description ?? payload.notes ?? undefined,
description: payload.description ?? undefined,
structure: payload.structure ?? undefined,
})
const normalized = normalizeItem(data)
state.types.value.push(normalized)
showSuccess(`Type de ${label} "${data.name}" créé`)
return { success: true, data: normalized }
} catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue'
showError(`Erreur lors de la création du type de ${label}: ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
}
}
const updateType = async (id: string, payload: EntityTypePayload): Promise<EntityTypeResult> => {
state.loading.value = true
try {
const data = await updateModelType(id, {
name: payload.name,
description: payload.description ?? undefined,
notes: payload.notes ?? undefined,
code: payload.code,
structure: payload.structure ?? undefined,
})
const normalized = normalizeItem(data)
const index = state.types.value.findIndex((t) => t.id === id)
if (index !== -1) state.types.value[index] = normalized
showSuccess(`Type de ${label} "${data.name}" mis à jour`)
return { success: true, data: normalized }
} catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue'
showError(`Erreur lors de la mise à jour du type de ${label}: ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
}
}
const deleteType = async (id: string): Promise<EntityTypeResult> => {
state.loading.value = true
try {
await deleteModelType(id)
state.types.value = state.types.value.filter((t) => t.id !== id)
showSuccess(`Type de ${label} supprimé`)
return { success: true }
} catch (error) {
const err = error as Error & { data?: { message?: string }; message?: string }
const message = err?.data?.message || err?.message || 'Erreur inconnue'
showError(`Erreur lors de la suppression du type de ${label}: ${message}`)
return { success: false, error: message }
} finally {
state.loading.value = false
}
}
return {
types: state.types,
loading: state.loading,
loadTypes,
createType,
updateType,
deleteType,
}
}

View File

@@ -0,0 +1,458 @@
/**
* Machine creation page orchestration composable.
*
* Consolidates entity lookup maps, option filters, label helpers,
* template wrappers, and the finalization logic that were previously
* inlined in pages/machines/new.vue.
*/
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines'
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 { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
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() {
// ---------------------------------------------------------------------------
// Composable calls
// ---------------------------------------------------------------------------
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
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()
// ---------------------------------------------------------------------------
// Local state
// ---------------------------------------------------------------------------
const submitting = ref(false)
const newMachine = reactive({
name: '',
siteId: '',
typeMachineId: '',
reference: '',
})
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
// ---------------------------------------------------------------------------
const finalizeMachineCreation = async () => {
if (submitting.value) return
const type = selectedMachineType.value
if (!type) {
toast.showError('Merci de sélectionner un type de machine')
return
}
if (!canCreateMachine.value) {
toast.showError('Compléter les informations obligatoires avant de créer la machine')
return
}
submitting.value = true
try {
const baseMachineData = {
name: newMachine.name,
siteId: newMachine.siteId,
reference: newMachine.reference,
typeMachineId: type.id,
}
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 (hasRequirements && result.data?.id) {
const skeletonResult: any = await reconfigureSkeleton(result.data.id, {
componentLinks,
pieceLinks,
productLinks,
} as any)
if (!skeletonResult.success) {
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
return
}
}
newMachine.name = ''
newMachine.siteId = ''
newMachine.typeMachineId = ''
newMachine.reference = ''
clearRequirementSelections()
await navigateTo('/machines')
} else if (result.error) {
toast.showError(`Impossible de créer la machine: ${result.error}`)
}
} catch (error: any) {
toast.showError(`Erreur lors de la création: ${error.message}`)
} finally {
submitting.value = false
}
}
// ---------------------------------------------------------------------------
// Watchers & 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 () => {
await Promise.all([
loadSites(),
loadMachineTypes(),
loadComposants(),
loadPieces(),
loadProducts(),
])
})
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
// State
submitting,
newMachine,
sites,
machineTypes,
machineTypesLoading,
composantsLoading,
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
finalizeMachineCreation,
}
}

View File

@@ -0,0 +1,572 @@
/**
* 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

@@ -0,0 +1,365 @@
/**
* 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,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,298 @@
/**
* Builds a component/piece hierarchy tree from flat machine link arrays.
*
* Extracted from pages/machine/[id].vue to keep the page orchestrator lean.
*/
import { resolveIdentifier, getProductDisplay } from '~/shared/utils/productDisplayUtils'
import { resolveConstructeurs, uniqueConstructeurIds, type ConstructeurSummary } from '~/shared/constructeurUtils'
type AnyRecord = Record<string, unknown>
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const collectConstructeurs = (
allConstructeurs: AnyRecord[],
...sources: unknown[]
): AnyRecord[] => {
const ids = uniqueConstructeurIds(...sources)
if (!ids.length) return []
const pools = sources
.flatMap((source) => {
if (Array.isArray(source)) return [source]
if (source && typeof source === 'object' && (source as AnyRecord).id) return [[source]]
return []
})
.filter(Boolean) as AnyRecord[][]
// ConstructeurSummary and AnyRecord are structurally compatible at runtime
const allPools = [...pools, allConstructeurs] as unknown as Array<ConstructeurSummary[]>
return resolveConstructeurs(ids, ...allPools) as unknown as AnyRecord[]
}
// ---------------------------------------------------------------------------
// Link array resolution
// ---------------------------------------------------------------------------
export const resolveLinkArray = (source: unknown, keys: string[]): unknown[] | null => {
if (!source || typeof source !== 'object') return null
for (const key of keys) {
const value = (source as AnyRecord)[key]
if (Array.isArray(value)) return value
}
return null
}
// ---------------------------------------------------------------------------
// Merge trees (for incremental updates)
// ---------------------------------------------------------------------------
export function mergePieceLists(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] {
if (!existing.length) {
return updates.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] }))
}
if (!updates.length) {
return existing.map((piece) => ({ ...piece, constructeurs: piece.constructeurs || [] }))
}
const updateMap = new Map<unknown, AnyRecord>(
updates.map((piece) => [piece.id, { ...piece, constructeurs: piece.constructeurs || [] }]),
)
const merged = existing.map((piece) => {
const update = updateMap.get(piece.id)
if (!update) return piece
return { ...piece, ...update, customFields: update.customFields ?? piece.customFields }
})
updates.forEach((update) => {
if (!existing.some((piece) => piece.id === update.id)) merged.push(update)
})
return merged
}
export function mergeComponentTrees(existing: AnyRecord[] = [], updates: AnyRecord[] = []): AnyRecord[] {
if (!existing.length) {
return updates.map((component) => ({
...component,
constructeurs: component.constructeurs || [],
pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({
...piece,
constructeurs: piece.constructeurs || [],
})),
subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]),
}))
}
if (!updates.length) return existing
const updateMap = new Map<unknown, AnyRecord>(
updates.map((component) => [
component.id,
{
...component,
constructeurs: component.constructeurs || [],
pieces: ((component.pieces || []) as AnyRecord[]).map((piece) => ({
...piece,
constructeurs: piece.constructeurs || [],
})),
subComponents: mergeComponentTrees([], (component.subComponents || []) as AnyRecord[]),
},
]),
)
const merged: AnyRecord[] = existing.map((component) => {
const update = updateMap.get(component.id)
if (!update) {
return {
...component,
constructeurs: component.constructeurs || [],
pieces: mergePieceLists((component.pieces || []) as AnyRecord[], []),
subComponents: mergeComponentTrees((component.subComponents || []) as AnyRecord[], []),
}
}
return {
...component,
...update,
customFields: update.customFields ?? component.customFields,
pieces: mergePieceLists((component.pieces || []) as AnyRecord[], (update.pieces || []) as AnyRecord[]),
subComponents: mergeComponentTrees(
(component.subComponents || []) as AnyRecord[],
(update.subComponents || []) as AnyRecord[],
),
}
})
updates.forEach((update) => {
if (!existing.some((component) => component.id === update.id)) merged.push(update)
})
return merged
}
// ---------------------------------------------------------------------------
// Build hierarchy from links
// ---------------------------------------------------------------------------
export const buildMachineHierarchyFromLinks = (
componentLinks: AnyRecord[] = [],
pieceLinks: AnyRecord[] = [],
findProductById: (id: string) => AnyRecord | null,
allConstructeurs: AnyRecord[] = [],
): { components: AnyRecord[]; machinePieces: AnyRecord[] } => {
const normalizeComponentLinkId = (link: AnyRecord) =>
resolveIdentifier(link?.id, link?.linkId, link?.machineComponentLinkId)
const normalizePieceLinkId = (link: AnyRecord) =>
resolveIdentifier(link?.id, link?.linkId, link?.machinePieceLinkId)
const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => {
if (!link || typeof link !== 'object') return null
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 requirement = (link.typeMachinePieceRequirement || appliedPiece.typeMachinePieceRequirement || originalPiece?.typeMachinePieceRequirement || null) as AnyRecord | null
const machinePieceLinkId = normalizePieceLinkId(link)
const pieceId = resolveIdentifier(appliedPiece.id, appliedPiece.pieceId, link.pieceId)
const overrides = (link.overrides || null) as AnyRecord | null
const basePiece: AnyRecord = {
...appliedPiece,
id: appliedPiece.id || pieceId || machinePieceLinkId || `piece-${machinePieceLinkId}`,
pieceId,
name: overrides?.name || appliedPiece.name || (appliedPiece.definition as AnyRecord)?.name || (appliedPiece.definition as AnyRecord)?.role || originalPiece?.name || 'Pièce',
reference: overrides?.reference || appliedPiece.reference || (appliedPiece.definition as AnyRecord)?.reference || originalPiece?.reference || null,
prix: overrides?.prix ?? appliedPiece.prix ?? originalPiece?.prix ?? null,
constructeur: appliedPiece.constructeur || originalPiece?.constructeur || 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 : [],
typePiece: appliedPiece.typePiece || requirement?.typePiece || null,
typePieceId: appliedPiece.typePieceId || (appliedPiece.typePiece as AnyRecord)?.id || requirement?.typePieceId || (requirement?.typePiece as AnyRecord)?.id || null,
typeMachinePieceRequirement: requirement,
typeMachinePieceRequirementId: requirement?.id || null,
requirementId: requirement?.id || null,
overrides,
originalPiece,
machinePieceLink: link,
machinePieceLinkId,
linkId: machinePieceLinkId,
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedPiece.parentComponentLinkId),
parentComponentId: resolveIdentifier(appliedPiece.parentComponentId, link.parentComponentId),
parentComponentName,
parentLinkId: resolveIdentifier(link.parentLinkId, link.parentMachinePieceLinkId, appliedPiece.parentLinkId),
parentPieceLinkId: resolveIdentifier(link.parentPieceLinkId, appliedPiece.parentPieceLinkId),
parentPieceId: resolveIdentifier(appliedPiece.parentPieceId, link.parentPieceId),
parentMachineComponentRequirementId: resolveIdentifier(appliedPiece.parentMachineComponentRequirementId, link.parentMachineComponentRequirementId),
parentMachinePieceRequirementId: resolveIdentifier(appliedPiece.parentMachinePieceRequirementId, link.parentMachinePieceRequirementId),
definition: appliedPiece.definition || originalPiece?.definition || {},
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 resolvedProduct = (appliedPiece.product || link.product || originalPiece?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null
const constructeurs = collectConstructeurs(allConstructeurs, appliedPiece.constructeurs, appliedPiece.constructeur, appliedPiece.constructeurIds, appliedPiece.constructeurId, originalPiece?.constructeurs, originalPiece?.constructeur, originalPiece?.constructeurIds, originalPiece?.constructeurId)
return {
...basePiece,
constructeurs,
constructeur: constructeurs[0] || basePiece.constructeur || null,
constructeurId: (constructeurs[0] as AnyRecord)?.id || basePiece.constructeurId || null,
productId: resolvedProductId || appliedPiece.productId || null,
product: resolvedProduct || appliedPiece.product || null,
__productDisplay: getProductDisplay({ product: resolvedProduct || appliedPiece.product || null, productId: resolvedProductId || appliedPiece.productId || null } as AnyRecord, findProductById),
}
}
const createComponentNode = (link: AnyRecord): AnyRecord | null => {
if (!link || typeof link !== 'object') return null
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 requirement = (link.typeMachineComponentRequirement || appliedComponent.typeMachineComponentRequirement || originalComponent?.typeMachineComponentRequirement || null) as AnyRecord | null
const machineComponentLinkId = normalizeComponentLinkId(link)
const composantId = resolveIdentifier(appliedComponent.id, appliedComponent.composantId, link.composantId)
const compOverrides = (link.overrides || null) as AnyRecord | null
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)
? (link.pieceLinks as AnyRecord[]).map((pl) => createPieceNode(pl, componentName)).filter(Boolean) as AnyRecord[]
: []
const subComponents = Array.isArray(link.childLinks)
? (link.childLinks as AnyRecord[]).map(createComponentNode).filter(Boolean) as AnyRecord[]
: []
const resolvedProductId = resolveIdentifier(appliedComponent.productId, (appliedComponent.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalComponent?.productId, (originalComponent?.product as AnyRecord)?.id)
const resolvedProduct = (appliedComponent.product || link.product || originalComponent?.product || (resolvedProductId ? findProductById(resolvedProductId) : null) || null) as AnyRecord | null
const baseComponent: AnyRecord = {
...appliedComponent,
id: appliedComponent.id || composantId || machineComponentLinkId || `component-${machineComponentLinkId}`,
composantId,
name: componentName,
reference: compOverrides?.reference || appliedComponent.reference || (appliedComponent.definition as AnyRecord)?.reference || originalComponent?.reference || null,
prix: compOverrides?.prix ?? appliedComponent.prix ?? originalComponent?.prix ?? null,
constructeur: appliedComponent.constructeur || originalComponent?.constructeur || 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 : [],
typeComposant: appliedComponent.typeComposant || requirement?.typeComposant || null,
typeComposantId: appliedComponent.typeComposantId || (appliedComponent.typeComposant as AnyRecord)?.id || requirement?.typeComposantId || (requirement?.typeComposant as AnyRecord)?.id || null,
typeMachineComponentRequirement: requirement,
typeMachineComponentRequirementId: requirement?.id || null,
requirementId: requirement?.id || null,
overrides: compOverrides,
machineComponentLinkOverrides: compOverrides,
definitionOverrides: compOverrides,
originalComposant: originalComponent,
machineComponentLink: link,
machineComponentLinkId,
componentLinkId: machineComponentLinkId,
parentComponentLinkId: resolveIdentifier(link.parentComponentLinkId, link.parentLinkId, link.parentMachineComponentLinkId, appliedComponent.parentComponentLinkId),
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 || {},
customFields: appliedComponent.customFields || [],
pieces,
subComponents,
subcomponents: 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)
return {
...baseComponent,
constructeurs,
constructeur: constructeurs[0] || baseComponent.constructeur || null,
constructeurId: (constructeurs[0] as AnyRecord)?.id || baseComponent.constructeurId || null,
productId: resolvedProductId || appliedComponent.productId || null,
product: resolvedProduct || appliedComponent.product || null,
__productDisplay: getProductDisplay({ product: resolvedProduct || appliedComponent.product || null, productId: resolvedProductId || appliedComponent.productId || null } as AnyRecord, findProductById),
}
}
const rootComponents = (Array.isArray(componentLinks) ? componentLinks : [])
.filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId))
.map(createComponentNode)
.filter(Boolean) as AnyRecord[]
const machinePieces = (Array.isArray(pieceLinks) ? pieceLinks : [])
.filter((link) => !resolveIdentifier(link?.parentComponentLinkId, link?.parentLinkId, link?.parentMachineComponentLinkId))
.map((link) => createPieceNode(link, null))
.filter(Boolean) as AnyRecord[]
return { components: rootComponents, machinePieces }
}

View File

@@ -0,0 +1,163 @@
/**
* Machine print selection and execution logic.
*
* Extracted from pages/machine/[id].vue.
*/
import { ref, reactive, nextTick } from 'vue'
import { buildMachinePrintContext, buildMachinePrintHtml } from '~/utils/printTemplates/machineReport'
type AnyRecord = Record<string, unknown>
export interface PrintSelection {
machine: { info: boolean; customFields: boolean; documents: boolean }
components: Record<string, boolean>
pieces: Record<string, boolean>
}
export function useMachinePrint() {
const printModalOpen = ref(false)
const printSelection = reactive<PrintSelection>({
machine: { info: true, customFields: true, documents: true },
components: {},
pieces: {},
})
const ensurePrintSelectionEntries = (
components: AnyRecord[],
machinePieces: AnyRecord[],
) => {
printSelection.machine.info ??= true
printSelection.machine.customFields ??= true
printSelection.machine.documents ??= true
const ensureComponent = (component: AnyRecord) => {
if (component?.id !== undefined && printSelection.components[component.id as string] === undefined) {
printSelection.components[component.id as string] = true
}
;((component.pieces || []) as AnyRecord[]).forEach((piece) => {
if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) {
printSelection.pieces[piece.id as string] = true
}
})
;((component.subComponents || []) as AnyRecord[]).forEach(ensureComponent)
}
components.forEach(ensureComponent)
machinePieces.forEach((piece) => {
if (piece?.id !== undefined && printSelection.pieces[piece.id as string] === undefined) {
printSelection.pieces[piece.id as string] = true
}
})
}
const setAllPrintSelection = (
value: boolean,
components: AnyRecord[],
machinePieces: AnyRecord[],
) => {
ensurePrintSelectionEntries(components, machinePieces)
printSelection.machine.info = value
printSelection.machine.customFields = value
printSelection.machine.documents = value
Object.keys(printSelection.components).forEach((key) => {
printSelection.components[key] = value
})
Object.keys(printSelection.pieces).forEach((key) => {
printSelection.pieces[key] = value
})
}
const openPrintModal = (components: AnyRecord[], machinePieces: AnyRecord[]) => {
ensurePrintSelectionEntries(components, machinePieces)
printModalOpen.value = true
}
const closePrintModal = () => {
printModalOpen.value = false
}
const printMachine = (
machine: AnyRecord,
machineName: string,
machineReference: string,
machinePieces: AnyRecord[],
components: AnyRecord[],
currentSelection: PrintSelection = printSelection,
) => {
if (typeof window === 'undefined') return
// machineReport.js has no type annotations; cast to avoid inferred never[] params
const context = (buildMachinePrintContext as unknown as (config: Record<string, unknown>) => Record<string, unknown>)({
machine,
machineName,
machineReference,
machinePieces,
components,
selection: currentSelection,
})
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style'))
.map((node) => node.outerHTML)
.join('')
const htmlContent = buildMachinePrintHtml(context, styles)
const iframe = document.createElement('iframe')
iframe.style.position = 'fixed'
iframe.style.right = '0'
iframe.style.bottom = '0'
iframe.style.width = '0'
iframe.style.height = '0'
iframe.style.border = '0'
iframe.setAttribute('title', 'print-frame')
document.body.appendChild(iframe)
const iframeWindow = iframe.contentWindow
const iframeDocument = iframe.contentDocument || iframeWindow?.document
if (!iframeDocument || !iframeWindow) {
iframe.remove()
return
}
iframeDocument.open()
iframeDocument.write(htmlContent)
iframeDocument.close()
const triggerPrint = () => {
iframeWindow.focus()
iframeWindow.print()
setTimeout(() => {
iframe.remove()
}, 1000)
}
if (iframeDocument.readyState === 'complete') {
setTimeout(triggerPrint, 50)
} else {
iframe.onload = () => setTimeout(triggerPrint, 50)
}
}
const handlePrintConfirm = async (
machine: AnyRecord,
machineName: string,
machineReference: string,
machinePieces: AnyRecord[],
components: AnyRecord[],
) => {
closePrintModal()
await nextTick()
printMachine(machine, machineName, machineReference, machinePieces, components, printSelection)
}
return {
printModalOpen,
printSelection,
ensurePrintSelectionEntries,
setAllPrintSelection,
openPrintModal,
closePrintModal,
printMachine,
handlePrintConfirm,
}
}

View File

@@ -0,0 +1,838 @@
/**
* 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,81 +1,79 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi, type ApiResponse } from './useApi'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
const machineTypes = ref([]) 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 loading = ref(false)
const loaded = ref(false)
const normalizeRequirementList = (value, relationKey) => { const normalizeRequirementList = (value: unknown, relationKey: string): MachineTypeRequirement[] => {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return [] return []
} }
return value.map((entry, index) => { return value.map((entry: Record<string, unknown>, _index: number) => {
if (!entry || typeof entry !== 'object') { if (!entry || typeof entry !== 'object') {
return entry return entry
} }
const normalized = { ...entry } const normalized = { ...entry }
const relationField = relationKey.replace('Id', '') const relationField = relationKey.replace('Id', '')
const relationValue = normalized[relationField] const relationValue = normalized[relationField]
console.log(`[normalizeRequirementList] Entry ${index}:`, {
relationKey,
relationField,
hasRelationKey: !!normalized[relationKey],
relationValue,
relationValueType: typeof relationValue
})
if (relationKey && !normalized[relationKey]) { if (relationKey && !normalized[relationKey]) {
const relationId = extractRelationId(relationValue) const relationId = extractRelationId(relationValue)
console.log(`[normalizeRequirementList] Extracted ID:`, relationId)
if (relationId) { if (relationId) {
normalized[relationKey] = relationId normalized[relationKey] = relationId
} }
} }
console.log(`[normalizeRequirementList] Normalized entry:`, normalized) return normalized as MachineTypeRequirement
return normalized
}) })
} }
const normalizeMachineType = (type) => { const normalizeMachineType = (type: Record<string, unknown>): MachineType | null => {
if (!type || typeof type !== 'object') { if (!type || typeof type !== 'object') {
return type return null
} }
return { return {
...type, ...type,
componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'), componentRequirements: normalizeRequirementList(type.componentRequirements, 'typeComposantId'),
pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'), pieceRequirements: normalizeRequirementList(type.pieceRequirements, 'typePieceId'),
productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'), productRequirements: normalizeRequirementList(type.productRequirements, 'typeProductId'),
} } as MachineType
} }
const extractCollection = (payload) => { export function useMachineTypesApi() {
if (Array.isArray(payload)) { const { showSuccess } = useToast()
return payload
}
if (Array.isArray(payload?.member)) {
return payload.member
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
}
export function useMachineTypesApi () {
const { showSuccess, showError, showInfo } = useToast()
const { get, post, put, delete: del } = useApi() const { get, post, put, delete: del } = useApi()
const loadMachineTypes = async () => { const loadMachineTypes = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true loading.value = true
try { try {
const result = await get('/type_machines') const result = await get('/type_machines')
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
machineTypes.value = items.map(normalizeMachineType) machineTypes.value = items
showInfo(`Chargement de ${machineTypes.value.length} type(s) de machine réussi`) .map((item) => normalizeMachineType(item as Record<string, unknown>))
.filter((item): item is MachineType => item !== null)
loaded.value = true
} }
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des types de machines:', error) console.error('Erreur lors du chargement des types de machines:', error)
@@ -84,31 +82,32 @@ export function useMachineTypesApi () {
} }
} }
const createMachineType = async (typeData) => { const createMachineType = async (typeData: Partial<MachineType>): Promise<ApiResponse> => {
loading.value = true loading.value = true
try { try {
const result = await post('/type_machines', typeData) const result = await post('/type_machines', typeData)
if (result.success) { if (result.success) {
machineTypes.value.push(normalizeMachineType(result.data)) 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`) showSuccess(`Type de machine "${typeData.name}" créé avec succès`)
} }
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la création du type de machine:', error) console.error('Erreur lors de la création du type de machine:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const updateMachineType = async (id, typeData) => { const updateMachineType = async (id: string, typeData: Partial<MachineType>): Promise<ApiResponse> => {
loading.value = true loading.value = true
try { try {
const result = await put(`/type_machines/${id}`, typeData) const result = await put(`/type_machines/${id}`, typeData)
if (result.success) { if (result.success) {
const normalized = normalizeMachineType(result.data) const normalized = normalizeMachineType(result.data as Record<string, unknown>)
const index = machineTypes.value.findIndex(type => type.id === id) const index = machineTypes.value.findIndex((type) => type.id === id)
if (index !== -1) { if (index !== -1 && normalized) {
machineTypes.value[index] = normalized machineTypes.value[index] = normalized
} }
showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`) showSuccess(`Type de machine "${typeData.name}" mis à jour avec succès`)
@@ -116,34 +115,34 @@ export function useMachineTypesApi () {
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la mise à jour du type de machine:', error) console.error('Erreur lors de la mise à jour du type de machine:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const deleteMachineType = async (id) => { const deleteMachineType = async (id: string): Promise<ApiResponse> => {
loading.value = true loading.value = true
try { try {
const result = await del(`/type_machines/${id}`) const result = await del(`/type_machines/${id}`)
if (result.success) { if (result.success) {
const deletedType = machineTypes.value.find(type => type.id === id) const deletedType = machineTypes.value.find((type) => type.id === id)
machineTypes.value = machineTypes.value.filter(type => type.id !== id) machineTypes.value = machineTypes.value.filter((type) => type.id !== id)
showSuccess(`Type de machine "${deletedType?.name || 'inconnu'}" supprimé avec succès`) showSuccess(`Type de machine "${deletedType?.name || 'inconnu'}" supprimé avec succès`)
} }
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la suppression du type de machine:', error) console.error('Erreur lors de la suppression du type de machine:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const getMachineTypeById = async (id, forceRefresh = false) => { const getMachineTypeById = async (id: string, forceRefresh = false): Promise<ApiResponse> => {
// D'abord chercher dans le cache local (sauf si forceRefresh) // D'abord chercher dans le cache local (sauf si forceRefresh)
if (!forceRefresh) { if (!forceRefresh) {
const localType = machineTypes.value.find(type => type.id === id) const localType = machineTypes.value.find((type) => type.id === id)
if (localType) { if (localType) {
return { success: true, data: localType } return { success: true, data: localType }
} }
@@ -153,12 +152,12 @@ export function useMachineTypesApi () {
try { try {
const result = await get(`/type_machines/${id}`) const result = await get(`/type_machines/${id}`)
if (result.success) { if (result.success) {
const normalized = normalizeMachineType(result.data) const normalized = normalizeMachineType(result.data as Record<string, unknown>)
// Mettre à jour le cache local // Mettre à jour le cache local
const index = machineTypes.value.findIndex(type => type.id === id) const index = machineTypes.value.findIndex((type) => type.id === id)
if (index !== -1) { if (index !== -1 && normalized) {
machineTypes.value[index] = normalized machineTypes.value[index] = normalized
} else { } else if (normalized) {
machineTypes.value.push(normalized) machineTypes.value.push(normalized)
} }
return { success: true, data: normalized } return { success: true, data: normalized }
@@ -166,12 +165,12 @@ export function useMachineTypesApi () {
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la récupération du type de machine:', error) console.error('Erreur lors de la récupération du type de machine:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} }
} }
const getMachineTypes = () => machineTypes.value const getMachineTypes = (): MachineType[] => machineTypes.value
const isLoading = () => loading.value const isLoading = (): boolean => loading.value
return { return {
machineTypes, machineTypes,
@@ -182,6 +181,6 @@ export function useMachineTypesApi () {
deleteMachineType, deleteMachineType,
getMachineTypeById, getMachineTypeById,
getMachineTypes, getMachineTypes,
isLoading isLoading,
} }
} }

View File

@@ -1,13 +1,25 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi, type ApiResponse } from './useApi'
import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils' import { buildConstructeurRequestPayload } from '~/shared/constructeurUtils'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
const machines = ref([]) export interface Machine {
id: string
name?: string
siteId?: string | null
typeMachineId?: string | null
componentLinks?: unknown[]
pieceLinks?: unknown[]
[key: string]: unknown
}
const machines = ref<Machine[]>([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false)
const resolveLinkCollection = (source, keys) => { const resolveLinkCollection = (source: Record<string, unknown>, keys: string[]): unknown[] | undefined => {
if (!source || typeof source !== 'object') { if (!source || typeof source !== 'object') {
return undefined return undefined
} }
@@ -22,16 +34,17 @@ const resolveLinkCollection = (source, keys) => {
return undefined return undefined
} }
const normalizeMachineResponse = (payload) => { const normalizeMachineResponse = (payload: unknown): Machine | null => {
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
return null return null
} }
const container = payload.machine && typeof payload.machine === 'object' const raw = payload as Record<string, unknown>
? payload.machine const container = raw.machine && typeof raw.machine === 'object'
: payload ? raw.machine as Record<string, unknown>
: raw
const normalized = { ...container } const normalized: Record<string, unknown> = { ...container }
if (!normalized.siteId) { if (!normalized.siteId) {
const siteId = extractRelationId(container.site) const siteId = extractRelationId(container.site)
@@ -47,44 +60,35 @@ const normalizeMachineResponse = (payload) => {
} }
} }
const componentLinks = resolveLinkCollection(payload, ['componentLinks', 'machineComponentLinks']) ?? const componentLinks = resolveLinkCollection(raw, ['componentLinks', 'machineComponentLinks']) ??
resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ?? resolveLinkCollection(container, ['componentLinks', 'machineComponentLinks']) ??
[] []
const pieceLinks = resolveLinkCollection(payload, ['pieceLinks', 'machinePieceLinks']) ?? const pieceLinks = resolveLinkCollection(raw, ['pieceLinks', 'machinePieceLinks']) ??
resolveLinkCollection(container, ['pieceLinks', 'machinePieceLinks']) ?? resolveLinkCollection(container, ['pieceLinks', 'machinePieceLinks']) ??
[] []
normalized.componentLinks = componentLinks normalized.componentLinks = componentLinks
normalized.pieceLinks = pieceLinks normalized.pieceLinks = pieceLinks
return normalized return normalized as Machine
} }
export function useMachines () { export function useMachines() {
const { showSuccess, showError, showInfo } = useToast() const { showSuccess, showError } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
const loadMachines = async () => { const loadMachines = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true loading.value = true
try { try {
const result = await get('/machines') const result = await get('/machines')
if (result.success) { if (result.success) {
const machineList = Array.isArray(result.data) const machineList = extractCollection(result.data)
? result.data
: Array.isArray(result.data?.member)
? result.data.member
: Array.isArray(result.data?.['hydra:member'])
? result.data['hydra:member']
: Array.isArray(result.data?.machines)
? result.data.machines
: Array.isArray(result.data?.data)
? result.data.data
: []
const normalized = machineList const normalized = machineList
.map((item) => normalizeMachineResponse(item)) .map((item) => normalizeMachineResponse(item))
.filter(Boolean) .filter((item): item is Machine => item !== null)
machines.value = normalized machines.value = normalized
showInfo(`Chargement de ${normalized.length} machine(s) réussi`) loaded.value = true
} }
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des machines:', error) console.error('Erreur lors du chargement des machines:', error)
@@ -93,14 +97,14 @@ export function useMachines () {
} }
} }
const createMachine = async (machineData) => { const createMachine = async (machineData: Partial<Machine>): Promise<ApiResponse> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData)) const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
const result = await post('/machines', normalizedPayload) const result = await post('/machines', normalizedPayload)
if (result.success) { if (result.success) {
const createdMachine = normalizeMachineResponse(result.data) || const createdMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse(result.data?.machine) || normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
null null
if (createdMachine) { if (createdMachine) {
machines.value.push(createdMachine) machines.value.push(createdMachine)
@@ -111,34 +115,31 @@ export function useMachines () {
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la création de la machine:', error) console.error('Erreur lors de la création de la machine:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const createMachineFromType = async (machineData, typeMachine) => { const createMachineFromType = async (machineData: Partial<Machine>, typeMachine: { id: string }): Promise<ApiResponse> => {
// Créer la machine avec la structure héritée du type
const machineWithStructure = { const machineWithStructure = {
...machineData, ...machineData,
typeMachineId: typeMachine.id typeMachineId: typeMachine.id,
// La structure sera automatiquement héritée du type
// Les composants et pièces seront créés automatiquement
} }
return await createMachine(machineWithStructure) return await createMachine(machineWithStructure)
} }
const updateMachineData = async (id, machineData) => { const updateMachineData = async (id: string, machineData: Partial<Machine>): Promise<ApiResponse> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData)) const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(machineData))
const result = await patch(`/machines/${id}`, normalizedPayload) const result = await patch(`/machines/${id}`, normalizedPayload)
if (result.success) { if (result.success) {
const updatedMachine = normalizeMachineResponse(result.data) || const updatedMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse(result.data?.machine) || normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
null null
const index = machines.value.findIndex(machine => machine.id === id) const index = machines.value.findIndex((machine) => machine.id === id)
if (index !== -1 && updatedMachine) { if (index !== -1 && updatedMachine) {
machines.value[index] = { machines.value[index] = {
...machines.value[index], ...machines.value[index],
@@ -150,13 +151,13 @@ export function useMachines () {
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la mise à jour de la machine:', error) console.error('Erreur lors de la mise à jour de la machine:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const reconfigureSkeleton = async (machineId, payload) => { const reconfigureSkeleton = 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' }
} }
@@ -165,28 +166,28 @@ export function useMachines () {
try { try {
const result = await patch(`/machines/${machineId}/skeleton`, payload) const result = await patch(`/machines/${machineId}/skeleton`, 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) {
const updatedMachine = normalizeMachineResponse(result.data) || const updatedMachine = normalizeMachineResponse(result.data) ||
normalizeMachineResponse(result.data?.machine) || normalizeMachineResponse((result.data as Record<string, unknown>)?.machine) ||
machines.value[index] machines.value[index]
machines.value[index] = { machines.value[index] = {
...machines.value[index], ...machines.value[index],
...(updatedMachine || {}), ...updatedMachine,
} } as Machine
} }
showSuccess('Structure de la machine mise à jour avec succès') showSuccess('Structure de la machine mise à jour avec succès')
} }
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 reconfiguration du squelette de la machine:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const addMissingCustomFields = async (machineId, { showToast: shouldShowToast = true } = {}) => { const addMissingCustomFields = async (machineId: string, { showToast: shouldShowToast = true } = {}): Promise<ApiResponse> => {
if (!machineId) { if (!machineId) {
const error = 'Identifiant de machine manquant' const error = 'Identifiant de machine manquant'
if (shouldShowToast) { if (shouldShowToast) {
@@ -206,46 +207,46 @@ export function useMachines () {
} }
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de lajout des champs personnalisés manquants:', error) console.error('Erreur lors de l\'ajout des champs personnalisés manquants:', error)
if (shouldShowToast) { if (shouldShowToast) {
showError('Erreur lors de la complétion des champs personnalisés') showError('Erreur lors de la complétion des champs personnalisés')
} }
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} }
} }
const deleteMachine = async (id) => { const deleteMachine = async (id: string): Promise<ApiResponse> => {
loading.value = true loading.value = true
try { try {
const result = await del(`/machines/${id}`) const result = await del(`/machines/${id}`)
if (result.success) { if (result.success) {
const deletedMachine = machines.value.find(machine => machine.id === id) const deletedMachine = machines.value.find((machine) => machine.id === id)
machines.value = machines.value.filter(machine => machine.id !== id) machines.value = machines.value.filter((machine) => machine.id !== id)
showSuccess(`Machine "${deletedMachine?.name || 'inconnu'}" supprimée avec succès`) showSuccess(`Machine "${deletedMachine?.name || 'inconnu'}" supprimée avec succès`)
} }
return result return result
} catch (error) { } catch (error) {
console.error('Erreur lors de la suppression de la machine:', error) console.error('Erreur lors de la suppression de la machine:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const getMachineById = (id) => { const getMachineById = (id: string): Machine | undefined => {
return machines.value.find(machine => machine.id === id) return machines.value.find((machine) => machine.id === id)
} }
const getMachinesBySite = (siteId) => { const getMachinesBySite = (siteId: string): Machine[] => {
return machines.value.filter(machine => machine.siteId === siteId) return machines.value.filter((machine) => machine.siteId === siteId)
} }
const getMachinesByType = (typeMachineId) => { const getMachinesByType = (typeMachineId: string): Machine[] => {
return machines.value.filter(machine => machine.typeMachineId === typeMachineId) return machines.value.filter((machine) => machine.typeMachineId === typeMachineId)
} }
const getMachines = () => machines.value const getMachines = (): Machine[] => machines.value
const isLoading = () => loading.value const isLoading = (): boolean => loading.value
return { return {
machines, machines,
@@ -261,6 +262,6 @@ export function useMachines () {
getMachinesByType, getMachinesByType,
getMachines, getMachines,
isLoading, isLoading,
addMissingCustomFields addMissingCustomFields,
} }
} }

View File

@@ -0,0 +1,65 @@
import { ref, watch, onUnmounted } from 'vue'
import { useRoute } from '#imports'
export function useNavDropdown() {
const openDropdown = ref<string | null>(null)
let dropdownCloseTimer: ReturnType<typeof setTimeout> | null = null
const route = useRoute()
const setDropdown = (name: string) => {
if (dropdownCloseTimer) {
clearTimeout(dropdownCloseTimer)
dropdownCloseTimer = null
}
if (openDropdown.value !== name) {
openDropdown.value = name
}
}
const scheduleDropdownClose = (name: string) => {
if (dropdownCloseTimer) {
clearTimeout(dropdownCloseTimer)
}
dropdownCloseTimer = setTimeout(() => {
if (openDropdown.value === name) {
openDropdown.value = null
}
dropdownCloseTimer = null
}, 200)
}
const closeDropdownNow = () => {
if (dropdownCloseTimer) {
clearTimeout(dropdownCloseTimer)
dropdownCloseTimer = null
}
openDropdown.value = null
}
const toggleDropdown = (name: string) => {
if (openDropdown.value === name) {
closeDropdownNow()
return
}
setDropdown(name)
}
watch(() => route.fullPath, () => {
closeDropdownNow()
})
onUnmounted(() => {
if (dropdownCloseTimer) {
clearTimeout(dropdownCloseTimer)
dropdownCloseTimer = null
}
})
return {
openDropdown,
setDropdown,
scheduleDropdownClose,
closeDropdownNow,
toggleDropdown,
}
}

View File

@@ -0,0 +1,12 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type PieceHistoryActor = EntityHistoryActor
export type PieceHistoryEntry = EntityHistoryEntry
export function usePieceHistory() {
return useEntityHistory('piece')
}

View File

@@ -1,47 +0,0 @@
import { ref, computed } from 'vue'
let hasWarned = false
const warnDeprecated = () => {
if (hasWarned) return
if (process.dev) {
console.warn('[usePieceModels] Ce composable est conservé pour compatibilité mais les modèles ont été remplacés par les catégories enrichies de squelette. Utilisez usePieceTypes / usePieces à la place.')
}
hasWarned = true
}
const buildUnsupportedResult = () => ({
success: false,
error: 'Les modèles de pièces ont été retirés. Gérez les squelettes via les catégories et utilisez les requirements machine pour instancier des pièces.'
})
export function usePieceModels () {
warnDeprecated()
const pieceModelsBuckets = ref({})
const loadingPieceModels = ref(false)
const pieceModels = computed(() => [])
const noLongerSupported = async () => {
warnDeprecated()
return buildUnsupportedResult()
}
const getPieceModels = () => pieceModels.value
const getPieceModelsForType = () => []
const isPieceModelLoading = () => loadingPieceModels.value
return {
pieceModels,
pieceModelsBuckets,
loadingPieceModels,
loadPieceModels: noLongerSupported,
createPieceModel: noLongerSupported,
updatePieceModel: noLongerSupported,
deletePieceModel: noLongerSupported,
getPieceModels,
getPieceModelsForType,
isPieceModelLoading
}
}

View File

@@ -1,140 +0,0 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
const pieceTypes = ref([])
const loadingPieceTypes = ref(false)
export function usePieceTypes () {
const { showSuccess, showError } = useToast()
const generateCodeFromName = (name) => {
return (name || '')
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || 'type'
}
const loadPieceTypes = async () => {
loadingPieceTypes.value = true
try {
const data = await listModelTypes({
category: 'PIECE',
sort: 'name',
dir: 'asc',
limit: 200
})
pieceTypes.value = data.items.map(item => ({
...item,
description: item.description ?? item.notes ?? null
}))
return { success: true, data: pieceTypes.value }
} catch (error) {
const message = error?.message || 'Erreur inconnue'
showError(`Impossible de charger les types de pièce: ${message}`)
return { success: false, error: message }
} finally {
loadingPieceTypes.value = false
}
}
const createPieceType = async (payload) => {
loadingPieceTypes.value = true
try {
const data = await createModelType({
name: payload.name,
code: payload.code || generateCodeFromName(payload.name),
category: 'PIECE',
notes: payload.description ?? payload.notes,
description: payload.description ?? null,
structure: payload.structure
})
const normalized = {
...data,
description: data.description ?? data.notes ?? null
}
pieceTypes.value.push(normalized)
showSuccess(`Type de pièce "${data.name}" créé`)
return { success: true, data: normalized }
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la création du type de pièce: ${message}`)
return { success: false, error: message }
} finally {
loadingPieceTypes.value = false
}
}
const updatePieceType = async (id, payload) => {
loadingPieceTypes.value = true
try {
const data = await updateModelType(id, {
name: payload.name,
description: payload.description,
notes: payload.notes,
code: payload.code,
structure: payload.structure
})
const normalized = {
...data,
description: data.description ?? data.notes ?? null
}
const index = pieceTypes.value.findIndex(type => type.id === id)
if (index !== -1) {
pieceTypes.value[index] = normalized
}
showSuccess(`Type de pièce "${data.name}" mis à jour`)
return {
success: true,
data: normalized
}
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la mise à jour du type de pièce: ${message}`)
return { success: false, error: message }
} finally {
loadingPieceTypes.value = false
}
}
const deletePieceType = async (id) => {
loadingPieceTypes.value = true
try {
await deleteModelType(id)
pieceTypes.value = pieceTypes.value.filter(type => type.id !== id)
showSuccess('Type de pièce supprimé')
return { success: true }
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la suppression du type de pièce: ${message}`)
return { success: false, error: message }
} finally {
loadingPieceTypes.value = false
}
}
const getPieceTypes = () => pieceTypes.value
const isPieceTypeLoading = () => loadingPieceTypes.value
return {
pieceTypes,
loadingPieceTypes,
loadPieceTypes,
createPieceType,
updatePieceType,
deletePieceType,
getPieceTypes,
isPieceTypeLoading
}
}

View File

@@ -0,0 +1,29 @@
/**
* Backward-compatible wrapper around useEntityTypes.
* Preserves the original API surface (renamed fields) so consumers need no changes.
*/
import { useEntityTypes, type EntityType } from './useEntityTypes'
import type { PieceModelStructure } from '~/shared/types/inventory'
import type { Ref } from 'vue'
export interface PieceType extends EntityType {
structure: PieceModelStructure | null
}
export function usePieceTypes() {
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
category: 'PIECE',
label: 'pièce',
})
return {
pieceTypes: types as Ref<PieceType[]>,
loadingPieceTypes: loading,
loadPieceTypes: loadTypes,
createPieceType: createType,
updatePieceType: updateType,
deletePieceType: deleteType,
getPieceTypes: () => types.value as PieceType[],
isPieceTypeLoading: () => loading.value,
}
}

View File

@@ -2,45 +2,70 @@ import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
const pieces = ref([]) export interface Piece {
const total = ref(0) id: string
const loading = ref(false) name: string
reference?: string | null
const extractCollection = (payload) => { typePieceId?: string | null
if (Array.isArray(payload)) { typePiece?: { id: string; name?: string } | null
return payload productId?: string | null
} productIds?: string[]
if (Array.isArray(payload?.member)) { product?: { id: string; name?: string } | null
return payload.member constructeurs?: Constructeur[]
} constructeurIds?: string[]
if (Array.isArray(payload?.['hydra:member'])) { documents?: unknown[]
return payload['hydra:member'] createdAt?: string | null
} updatedAt?: string | null
if (Array.isArray(payload?.data)) { [key: string]: unknown
return payload.data
}
return []
} }
const extractTotal = (payload, fallbackLength) => { interface PieceListResult {
if (typeof payload?.totalItems === 'number') { success: boolean
return payload.totalItems data?: { items: Piece[]; total: number; page: number; itemsPerPage: number }
error?: string
}
interface PieceSingleResult {
success: boolean
data?: Piece
error?: string
}
interface LoadPiecesOptions {
search?: string
page?: number
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
force?: boolean
}
const pieces = ref<Piece[]>([])
const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
} }
if (typeof payload?.['hydra:totalItems'] === 'number') { if (typeof p?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems'] return p['hydra:totalItems']
} }
return fallbackLength return fallbackLength
} }
export function usePieces () { export function usePieces() {
const { showSuccess, showError, showInfo } = useToast() const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
const withResolvedConstructeurs = async (piece) => { const withResolvedConstructeurs = async (piece: Piece): Promise<Piece> => {
if (!piece || typeof piece !== 'object') { if (!piece || typeof piece !== 'object') {
return piece return piece
} }
@@ -68,12 +93,11 @@ export function usePieces () {
const ids = uniqueConstructeurIds( const ids = uniqueConstructeurIds(
piece.constructeurIds, piece.constructeurIds,
piece.constructeurs, piece.constructeurs,
piece.constructeur,
) )
const hasResolvedConstructeurs = const hasResolvedConstructeurs =
Array.isArray(piece.constructeurs) Array.isArray(piece.constructeurs) &&
&& piece.constructeurs.length > 0 piece.constructeurs.length > 0 &&
&& piece.constructeurs.every((item) => item && typeof item === 'object') piece.constructeurs.every((item) => item && typeof item === 'object')
if (ids.length && !hasResolvedConstructeurs) { if (ids.length && !hasResolvedConstructeurs) {
const resolved = await ensureConstructeurs(ids) const resolved = await ensureConstructeurs(ids)
@@ -85,36 +109,41 @@ export function usePieces () {
return piece return piece
} }
/** const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
* Load pieces with pagination and search support const {
* @param {Object} options - Query options search = '',
* @param {string} [options.search] - Search term for name/reference page = 1,
* @param {number} [options.page=1] - Current page (1-based) itemsPerPage = 30,
* @param {number} [options.itemsPerPage=30] - Items per page orderBy = 'name',
* @param {string} [options.orderBy='name'] - Field to order by orderDir = 'asc',
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc) force = false,
*/ } = options
const loadPieces = async (options = {}) => {
if (!force && loaded.value && !search && page === 1) {
return {
success: true,
data: { items: pieces.value, total: total.value, page, itemsPerPage },
}
}
if (loading.value) {
return {
success: true,
data: { items: pieces.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))
params.set('page', String(page)) params.set('page', String(page))
if (search && search.trim()) { if (search && search.trim()) {
// API Platform uses property filters
params.set('name', search.trim()) params.set('name', search.trim())
} }
// API Platform OrderFilter syntax: order[field]=direction
params.set(`order[${orderBy}]`, orderDir) params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/pieces?${params.toString()}`) const result = await get(`/pieces?${params.toString()}`)
@@ -123,85 +152,91 @@ export function usePieces () {
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item))) const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
pieces.value = enrichedItems pieces.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: {
items: enrichedItems, items: enrichedItems,
total: total.value, total: total.value,
page, page,
itemsPerPage itemsPerPage,
} },
} }
} }
return result return result as PieceListResult
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des pièces:', error) console.error('Erreur lors du chargement des pièces:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const createPiece = async (pieceData) => { const createPiece = async (pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData)) const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
const result = await post('/pieces', normalizedPayload) const result = await post('/pieces', normalizedPayload)
if (result.success) { if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data) const enriched = await withResolvedConstructeurs(result.data as Piece)
pieces.value.unshift(enriched) pieces.value.unshift(enriched)
total.value += 1 total.value += 1
const displayName = result.data?.name const definition = (pieceData as Record<string, unknown>)?.definition as Record<string, unknown> | undefined
|| pieceData?.definition?.name const displayName =
|| pieceData?.name (result.data as Piece)?.name ||
|| 'Pièce' (definition?.name as string | undefined) ||
pieceData?.name ||
'Pièce'
showSuccess(`Pièce "${displayName}" créée avec succès`) showSuccess(`Pièce "${displayName}" créée avec succès`)
return { success: true, data: enriched }
} }
return result return { success: false, error: result.error }
} catch (error) { } catch (error) {
console.error('Erreur lors de la création de la pièce:', error) console.error('Erreur lors de la création de la pièce:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const updatePieceData = async (id, pieceData) => { const updatePieceData = async (id: string, pieceData: Partial<Piece>): Promise<PieceSingleResult> => {
loading.value = true loading.value = true
try { try {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData)) const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(pieceData))
const result = await patch(`/pieces/${id}`, normalizedPayload) const result = await patch(`/pieces/${id}`, normalizedPayload)
if (result.success) { if (result.success && result.data) {
const updated = await withResolvedConstructeurs(result.data) const updated = await withResolvedConstructeurs(result.data as Piece)
const index = pieces.value.findIndex(piece => piece.id === id) const index = pieces.value.findIndex((piece) => piece.id === id)
if (index !== -1) { if (index !== -1) {
pieces.value[index] = updated pieces.value[index] = updated
} }
showSuccess(`Pièce "${updated?.name || pieceData.name || ''}" mise à jour avec succès`) showSuccess(`Pièce "${updated?.name || pieceData.name || ''}" mise à jour avec succès`)
return { success: true, data: updated }
} }
return result return { success: false, error: result.error }
} catch (error) { } catch (error) {
console.error('Erreur lors de la mise à jour de la pièce:', error) console.error('Erreur lors de la mise à jour de la pièce:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const deletePiece = async (id) => { const deletePiece = async (id: string): Promise<PieceSingleResult> => {
loading.value = true loading.value = true
try { try {
const result = await del(`/pieces/${id}`) const result = await del(`/pieces/${id}`)
if (result.success) { if (result.success) {
const deletedPiece = pieces.value.find(piece => piece.id === id) const deletedPiece = pieces.value.find((piece) => piece.id === id)
pieces.value = pieces.value.filter(piece => piece.id !== id) pieces.value = pieces.value.filter((piece) => piece.id !== id)
total.value = Math.max(0, total.value - 1) total.value = Math.max(0, total.value - 1)
showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`) showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`)
return { success: true }
} }
return result return { success: false, error: result.error }
} catch (error) { } catch (error) {
console.error('Erreur lors de la suppression de la pièce:', error) console.error('Erreur lors de la suppression de la pièce:', error)
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -210,15 +245,23 @@ export function usePieces () {
const getPieces = () => pieces.value const getPieces = () => pieces.value
const isLoading = () => loading.value const isLoading = () => loading.value
const clearPiecesCache = () => {
pieces.value = []
total.value = 0
loaded.value = false
}
return { return {
pieces, pieces,
total, total,
loading, loading,
loaded,
loadPieces, loadPieces,
createPiece, createPiece,
updatePiece: updatePieceData, updatePiece: updatePieceData,
deletePiece, deletePiece,
getPieces, getPieces,
isLoading isLoading,
clearPiecesCache,
} }
} }

View File

@@ -0,0 +1,12 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ProductHistoryActor = EntityHistoryActor
export type ProductHistoryEntry = EntityHistoryEntry
export function useProductHistory() {
return useEntityHistory('product')
}

View File

@@ -1,132 +0,0 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { listModelTypes, createModelType, updateModelType, deleteModelType } from '~/services/modelTypes'
const productTypes = ref([])
const loadingProductTypes = ref(false)
export function useProductTypes () {
const { showSuccess, showError } = useToast()
const generateCodeFromName = (name) => {
return (name || '')
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || 'type'
}
const loadProductTypes = async () => {
loadingProductTypes.value = true
try {
const data = await listModelTypes({
category: 'PRODUCT',
sort: 'name',
dir: 'asc',
limit: 200,
})
productTypes.value = data.items.map(item => ({
...item,
description: item.description ?? item.notes ?? null,
}))
return { success: true, data: productTypes.value }
} catch (error) {
const message = error?.message || 'Erreur inconnue'
showError(`Impossible de charger les types de produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
const createProductType = async (payload) => {
loadingProductTypes.value = true
try {
const data = await createModelType({
name: payload.name,
code: payload.code || generateCodeFromName(payload.name),
category: 'PRODUCT',
notes: payload.description ?? payload.notes,
description: payload.description ?? null,
structure: payload.structure,
})
const normalized = {
...data,
description: data.description ?? data.notes ?? null,
}
productTypes.value.push(normalized)
showSuccess(`Type de produit "${data.name}" créé`)
return { success: true, data: normalized }
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la création du type de produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
const updateProductType = async (id, payload) => {
loadingProductTypes.value = true
try {
const data = await updateModelType(id, {
name: payload.name,
description: payload.description,
notes: payload.notes,
code: payload.code,
structure: payload.structure,
})
const normalized = {
...data,
description: data.description ?? data.notes ?? null,
}
const index = productTypes.value.findIndex(type => type.id === id)
if (index !== -1) {
productTypes.value[index] = normalized
}
showSuccess(`Type de produit "${data.name}" mis à jour`)
return { success: true, data: normalized }
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la mise à jour du type de produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
const deleteProductType = async (id) => {
loadingProductTypes.value = true
try {
await deleteModelType(id)
productTypes.value = productTypes.value.filter(type => type.id !== id)
showSuccess('Type de produit supprimé')
return { success: true }
} catch (error) {
const message = error?.data?.message || error?.message || 'Erreur inconnue'
showError(`Erreur lors de la suppression du type de produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
return {
productTypes,
loadingProductTypes,
loadProductTypes,
createProductType,
updateProductType,
deleteProductType,
}
}

View File

@@ -0,0 +1,27 @@
/**
* Backward-compatible wrapper around useEntityTypes.
* Preserves the original API surface (renamed fields) so consumers need no changes.
*/
import { useEntityTypes, type EntityType } from './useEntityTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import type { Ref } from 'vue'
export interface ProductType extends EntityType {
structure: ProductModelStructure | null
}
export function useProductTypes() {
const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
category: 'PRODUCT',
label: 'produit',
})
return {
productTypes: types as Ref<ProductType[]>,
loadingProductTypes: loading,
loadProductTypes: loadTypes,
createProductType: createType,
updateProductType: updateType,
deleteProductType: deleteType,
}
}

View File

@@ -2,16 +2,53 @@ import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
const products = ref([]) export interface Product {
id: string
name: string
reference?: string | null
typeProductId?: string | null
typeProduct?: { id: string; name?: string } | null
constructeurs?: Constructeur[]
constructeurIds?: string[]
supplierPrice?: number | null
createdAt?: string | null
updatedAt?: string | null
documents?: unknown[]
[key: string]: unknown
}
interface ProductListResult {
success: boolean
data?: { items: Product[]; total: number; page: number; itemsPerPage: number }
error?: string
}
interface ProductSingleResult {
success: boolean
data?: Product
error?: string
}
interface LoadProductsOptions {
search?: string
page?: number
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
force?: boolean
}
const products = ref<Product[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const loaded = ref(false) const loaded = ref(false)
const error = ref(null) const error = ref<string | null>(null)
const replaceInCache = (item) => { const replaceInCache = (item: Product): boolean => {
if (!item?.id) { if (!item?.id) {
return false return false
} }
@@ -26,38 +63,23 @@ const replaceInCache = (item) => {
return false return false
} }
const extractCollection = (payload) => { const extractTotal = (payload: unknown, fallbackLength: number): number => {
if (Array.isArray(payload)) { const p = payload as Record<string, unknown> | null
return payload if (typeof p?.totalItems === 'number') {
return p.totalItems
} }
if (Array.isArray(payload?.member)) { if (typeof p?.['hydra:totalItems'] === 'number') {
return payload.member return p['hydra:totalItems']
}
if (Array.isArray(payload?.['hydra:member'])) {
return payload['hydra:member']
}
if (Array.isArray(payload?.data)) {
return payload.data
}
return []
}
const extractTotal = (payload, fallbackLength) => {
if (typeof payload?.totalItems === 'number') {
return payload.totalItems
}
if (typeof payload?.['hydra:totalItems'] === 'number') {
return payload['hydra:totalItems']
} }
return fallbackLength return fallbackLength
} }
export function useProducts () { export function useProducts() {
const { showError } = useToast() const { showError } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
const withResolvedConstructeurs = async (product) => { const withResolvedConstructeurs = async (product: Product): Promise<Product> => {
if (!product || typeof product !== 'object') { if (!product || typeof product !== 'object') {
return product return product
} }
@@ -70,12 +92,11 @@ export function useProducts () {
const ids = uniqueConstructeurIds( const ids = uniqueConstructeurIds(
product.constructeurIds, product.constructeurIds,
product.constructeurs, product.constructeurs,
product.constructeur,
) )
const hasResolvedConstructeurs = const hasResolvedConstructeurs =
Array.isArray(product.constructeurs) Array.isArray(product.constructeurs) &&
&& product.constructeurs.length > 0 product.constructeurs.length > 0 &&
&& product.constructeurs.every((item) => item && typeof item === 'object') product.constructeurs.every((item) => item && typeof item === 'object')
if (ids.length && !hasResolvedConstructeurs) { if (ids.length && !hasResolvedConstructeurs) {
const resolved = await ensureConstructeurs(ids) const resolved = await ensureConstructeurs(ids)
@@ -87,26 +108,23 @@ export function useProducts () {
return product return product
} }
/** const loadProducts = async (options: LoadProductsOptions = {}): Promise<ProductListResult> => {
* Load products with pagination and search support
* @param {Object} options - Query options
* @param {string} [options.search] - Search term for name/reference
* @param {number} [options.page=1] - Current page (1-based)
* @param {number} [options.itemsPerPage=30] - Items per page
* @param {string} [options.orderBy='name'] - Field to order by
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
* @param {boolean} [options.force=false] - Force reload even if already loaded
*/
const loadProducts = async (options = {}) => {
const { const {
search = '', search = '',
page = 1, page = 1,
itemsPerPage = 30, itemsPerPage = 30,
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
force = false force = false,
} = options } = options
if (!force && loaded.value && !search && page === 1) {
return {
success: true,
data: { items: products.value, total: total.value, page, itemsPerPage },
}
}
if (loading.value) { if (loading.value) {
return { return {
success: true, success: true,
@@ -140,17 +158,17 @@ export function useProducts () {
items: enrichedItems, items: enrichedItems,
total: total.value, total: total.value,
page, page,
itemsPerPage itemsPerPage,
} },
} }
} else if (result.error) { } else if (result.error) {
error.value = result.error error.value = result.error
showError(`Impossible de charger les produits: ${result.error}`) showError(`Impossible de charger les produits: ${result.error}`)
} }
return result return result as ProductListResult
} catch (err) { } catch (err) {
console.error('Erreur lors du chargement des produits:', err) console.error('Erreur lors du chargement des produits:', err)
const message = err?.message ?? 'Erreur inconnue' const message = (err as Error)?.message ?? 'Erreur inconnue'
error.value = message error.value = message
showError(`Impossible de charger les produits: ${message}`) showError(`Impossible de charger les produits: ${message}`)
return { success: false, error: message } return { success: false, error: message }
@@ -159,26 +177,27 @@ export function useProducts () {
} }
} }
const createProduct = async (payload) => { const createProduct = async (payload: Partial<Product>): Promise<ProductSingleResult> => {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload)) const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const result = await post('/products', normalizedPayload) const result = await post('/products', normalizedPayload)
if (result.success && result.data) { if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data) const enriched = await withResolvedConstructeurs(result.data as Product)
const added = replaceInCache(enriched) const added = replaceInCache(enriched)
if (added) { if (added) {
total.value += 1 total.value += 1
} }
return { success: true, data: enriched }
} else if (result.error) { } else if (result.error) {
error.value = result.error error.value = result.error
showError(result.error) showError(result.error)
} }
return result return { success: false, error: result.error }
} catch (err) { } catch (err) {
console.error('Erreur lors de la création du produit:', err) console.error('Erreur lors de la création du produit:', err)
const message = err?.message ?? 'Erreur inconnue' const message = (err as Error)?.message ?? 'Erreur inconnue'
error.value = message error.value = message
showError(message) showError(message)
return { success: false, error: message } return { success: false, error: message }
@@ -187,23 +206,24 @@ export function useProducts () {
} }
} }
const updateProduct = async (id, payload) => { const updateProduct = async (id: string, payload: Partial<Product>): Promise<ProductSingleResult> => {
const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload)) const normalizedPayload = normalizeRelationIds(buildConstructeurRequestPayload(payload))
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const result = await patch(`/products/${id}`, normalizedPayload) const result = await patch(`/products/${id}`, normalizedPayload)
if (result.success && result.data) { if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data) const enriched = await withResolvedConstructeurs(result.data as Product)
replaceInCache(enriched) replaceInCache(enriched)
return { success: true, data: enriched }
} else if (result.error) { } else if (result.error) {
error.value = result.error error.value = result.error
showError(result.error) showError(result.error)
} }
return result return { success: false, error: result.error }
} catch (err) { } catch (err) {
console.error('Erreur lors de la mise à jour du produit:', err) console.error('Erreur lors de la mise à jour du produit:', err)
const message = err?.message ?? 'Erreur inconnue' const message = (err as Error)?.message ?? 'Erreur inconnue'
error.value = message error.value = message
showError(message) showError(message)
return { success: false, error: message } return { success: false, error: message }
@@ -212,23 +232,23 @@ export function useProducts () {
} }
} }
const deleteProduct = async (id) => { const deleteProduct = async (id: string): Promise<ProductSingleResult> => {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const result = await del(`/products/${id}`) const result = await del(`/products/${id}`)
if (result.success) { if (result.success) {
const removed = products.value.find((product) => product.id === id)
products.value = products.value.filter((product) => product.id !== id) products.value = products.value.filter((product) => product.id !== id)
total.value = Math.max(0, total.value - 1) total.value = Math.max(0, total.value - 1)
return { success: true }
} else if (result.error) { } else if (result.error) {
error.value = result.error error.value = result.error
showError(result.error) showError(result.error)
} }
return result return { success: false, error: result.error }
} catch (err) { } catch (err) {
console.error('Erreur lors de la suppression du produit:', err) console.error('Erreur lors de la suppression du produit:', err)
const message = err?.message ?? 'Erreur inconnue' const message = (err as Error)?.message ?? 'Erreur inconnue'
error.value = message error.value = message
showError(message) showError(message)
return { success: false, error: message } return { success: false, error: message }
@@ -237,7 +257,7 @@ export function useProducts () {
} }
} }
const getProduct = async (id, options = {}) => { const getProduct = async (id: string, options: { force?: boolean } = {}): Promise<ProductSingleResult> => {
const shouldForce = !!options.force const shouldForce = !!options.force
if (!shouldForce) { if (!shouldForce) {
const cached = products.value.find((product) => product.id === id) const cached = products.value.find((product) => product.id === id)
@@ -249,14 +269,14 @@ export function useProducts () {
try { try {
const result = await get(`/products/${id}`) const result = await get(`/products/${id}`)
if (result.success && result.data) { if (result.success && result.data) {
const enriched = await withResolvedConstructeurs(result.data) const enriched = await withResolvedConstructeurs(result.data as Product)
replaceInCache(enriched) replaceInCache(enriched)
return { success: true, data: enriched } return { success: true, data: enriched }
} }
return result return { success: false, error: result.error }
} catch (err) { } catch (err) {
console.error('Erreur lors du chargement du produit:', err) console.error('Erreur lors du chargement du produit:', err)
const message = err?.message ?? 'Erreur inconnue' const message = (err as Error)?.message ?? 'Erreur inconnue'
return { success: false, error: message } return { success: false, error: message }
} }
} }

View File

@@ -1,35 +1,37 @@
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports' import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
import type { Profile } from './useProfiles'
const buildUrl = (path) => { const buildUrl = (path: string): string => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const baseUrl = process.server const baseUrl = import.meta.server
? (config.apiBaseUrl || config.public.apiBaseUrl || '') ? ((config.apiBaseUrl as string) || (config.public.apiBaseUrl as string) || '')
: (config.public.apiBaseUrl || '') : ((config.public.apiBaseUrl as string) || '')
const base = baseUrl.replace(/\/$/, '') const base = baseUrl.replace(/\/$/, '')
return `${base}${path}` return `${base}${path}`
} }
export function useProfileSession () { export function useProfileSession() {
const activeProfile = useState('profileSession:active', () => null) const activeProfile = useState<Profile | null>('profileSession:active', () => null)
const sessionLoaded = useState('profileSession:loaded', () => false) const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
const loading = useState('profileSession:loading', () => false) const loading = useState<boolean>('profileSession:loading', () => false)
const getSessionHeaders = () => { const getSessionHeaders = (): Record<string, string> | undefined => {
if (!process.server) { return undefined } if (!import.meta.server) { return undefined }
const headers = useRequestHeaders(['cookie']) const headers = useRequestHeaders(['cookie'])
return headers?.cookie ? { cookie: headers.cookie } : undefined return headers?.cookie ? { cookie: headers.cookie } : undefined
} }
const fetchCurrentProfile = async () => { const fetchCurrentProfile = async (): Promise<Profile | null> => {
loading.value = true loading.value = true
try { try {
activeProfile.value = await $fetch(buildUrl('/session/profile'), { activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
method: 'GET', method: 'GET',
credentials: 'include', credentials: 'include',
headers: getSessionHeaders() headers: getSessionHeaders(),
}) })
} catch (error) { } catch (error) {
if (error?.status === 401) { const err = error as { status?: number }
if (err?.status === 401) {
activeProfile.value = null activeProfile.value = null
} else { } else {
console.error('Erreur lors du chargement du profil actif', error) console.error('Erreur lors du chargement du profil actif', error)
@@ -42,29 +44,29 @@ export function useProfileSession () {
return activeProfile.value return activeProfile.value
} }
const ensureSession = () => { const ensureSession = (): Promise<Profile | null> => {
if (!sessionLoaded.value) { if (!sessionLoaded.value) {
return fetchCurrentProfile() return fetchCurrentProfile()
} }
return Promise.resolve(activeProfile.value) return Promise.resolve(activeProfile.value)
} }
const activateProfile = async (profileId) => { const activateProfile = async (profileId: string): Promise<void> => {
await $fetch(buildUrl('/session/profile'), { await $fetch(buildUrl('/session/profile'), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: { profileId }, body: { profileId },
headers: getSessionHeaders() headers: getSessionHeaders(),
}) })
await fetchCurrentProfile() await fetchCurrentProfile()
} }
const logout = async () => { const logout = async (): Promise<void> => {
try { try {
await $fetch(buildUrl('/session/profile'), { await $fetch(buildUrl('/session/profile'), {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
headers: getSessionHeaders() headers: getSessionHeaders(),
}) })
} finally { } finally {
activeProfile.value = null activeProfile.value = null
@@ -79,6 +81,6 @@ export function useProfileSession () {
ensureSession, ensureSession,
fetchCurrentProfile, fetchCurrentProfile,
activateProfile, activateProfile,
logout logout,
} }
} }

View File

@@ -1,67 +0,0 @@
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
const buildUrl = (path) => {
const config = useRuntimeConfig()
const base = config.public.apiBaseUrl?.replace(/\/$/, '') || ''
return `${base}${path}`
}
export function useProfiles () {
const profiles = useState('profiles:list', () => [])
const loadingProfiles = useState('profiles:loading', () => false)
const profilesLoaded = useState('profiles:loaded', () => false)
const getSessionHeaders = () => {
if (!process.server) { return undefined }
const headers = useRequestHeaders(['cookie'])
return headers?.cookie ? { cookie: headers.cookie } : undefined
}
const fetchProfiles = async () => {
loadingProfiles.value = true
try {
profiles.value = await $fetch(buildUrl('/session/profiles'), {
method: 'GET',
credentials: 'include',
headers: getSessionHeaders()
})
profilesLoaded.value = true
} catch (error) {
console.error('Erreur lors du chargement des profils', error)
profiles.value = []
profilesLoaded.value = false
} finally {
loadingProfiles.value = false
}
return profiles.value
}
const createProfile = async ({ firstName, lastName }) => {
const profile = await $fetch(buildUrl('/session/profiles'), {
method: 'POST',
credentials: 'include',
body: { firstName, lastName },
headers: getSessionHeaders()
})
await fetchProfiles()
return profile
}
const deleteProfile = async (profileId) => {
await $fetch(buildUrl(`/session/profiles/${profileId}`), {
method: 'DELETE',
credentials: 'include',
headers: getSessionHeaders()
})
await fetchProfiles()
}
return {
profiles,
loadingProfiles,
profilesLoaded,
fetchProfiles,
createProfile,
deleteProfile
}
}

View File

@@ -0,0 +1,74 @@
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
export interface Profile {
id: string
firstName: string
lastName: string
[key: string]: unknown
}
const buildUrl = (path: string): string => {
const config = useRuntimeConfig()
const base = (config.public.apiBaseUrl as string)?.replace(/\/$/, '') || ''
return `${base}${path}`
}
export function useProfiles() {
const profiles = useState<Profile[]>('profiles:list', () => [])
const loadingProfiles = useState<boolean>('profiles:loading', () => false)
const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
const getSessionHeaders = (): Record<string, string> | undefined => {
if (!import.meta.server) { return undefined }
const headers = useRequestHeaders(['cookie'])
return headers?.cookie ? { cookie: headers.cookie } : undefined
}
const fetchProfiles = async (): Promise<Profile[]> => {
loadingProfiles.value = true
try {
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
method: 'GET',
credentials: 'include',
headers: getSessionHeaders(),
})
profilesLoaded.value = true
} catch (error) {
console.error('Erreur lors du chargement des profils', error)
profiles.value = []
profilesLoaded.value = false
} finally {
loadingProfiles.value = false
}
return profiles.value
}
const createProfile = async ({ firstName, lastName }: { firstName: string; lastName: string }): Promise<Profile> => {
const profile = await $fetch<Profile>(buildUrl('/session/profiles'), {
method: 'POST',
credentials: 'include',
body: { firstName, lastName },
headers: getSessionHeaders(),
})
await fetchProfiles()
return profile
}
const deleteProfile = async (profileId: string): Promise<void> => {
await $fetch(buildUrl(`/session/profiles/${profileId}`), {
method: 'DELETE',
credentials: 'include',
headers: getSessionHeaders(),
})
await fetchProfiles()
}
return {
profiles,
loadingProfiles,
profilesLoaded,
fetchProfiles,
createProfile,
deleteProfile,
}
}

View File

@@ -3,6 +3,7 @@ import { navigateTo, useRoute } from '#imports'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConfirm } from '~/composables/useConfirm'
import { getFileIcon } from '~/utils/fileIcons' import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview' import { canPreviewDocument } from '~/utils/documentPreview'
@@ -41,6 +42,7 @@ export function useSiteManagement() {
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const { sites, loading, loadSites, createSite, updateSite, deleteSite } = useSites() const { sites, loading, loadSites, createSite, updateSite, deleteSite } = useSites()
const { uploadDocuments, deleteDocument, loadDocumentsBySite } = useDocuments() const { uploadDocuments, deleteDocument, loadDocumentsBySite } = useDocuments()
const { confirm: confirmDialog } = useConfirm()
const showAddSiteModal = ref(false) const showAddSiteModal = ref(false)
const showEditSiteModal = ref(false) const showEditSiteModal = ref(false)
@@ -132,7 +134,8 @@ export function useSiteManagement() {
if (index !== -1) { if (index !== -1) {
sites.value[index] = { sites.value[index] = {
...sites.value[index], ...sites.value[index],
...updated ...updated,
id,
} }
} }
} }
@@ -166,8 +169,9 @@ export function useSiteManagement() {
) )
uploadingDocuments.value = false uploadingDocuments.value = false
if (uploadResult.success) { if (uploadResult.success && uploadResult.data) {
uploadedDocuments = uploadResult.data || [] const data = uploadResult.data
uploadedDocuments = (Array.isArray(data) ? data : [data]) as SiteDocument[]
selectedFiles.value = [] selectedFiles.value = []
} }
} }
@@ -250,9 +254,9 @@ export function useSiteManagement() {
const confirmDeleteSite = async (site: SiteWithDocuments) => { const confirmDeleteSite = async (site: SiteWithDocuments) => {
if ( if (
!confirm( !await confirmDialog({
`Êtes-vous sûr de vouloir supprimer le site "${site.name}" ? Cette action est irréversible.` message: `Êtes-vous sûr de vouloir supprimer le site "${site.name}" ? Cette action est irréversible.`,
) })
) { ) {
return return
} }

View File

@@ -1,111 +0,0 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
const sites = ref([])
const loading = ref(false)
export function useSites () {
const { showSuccess, showInfo } = useToast()
const { get, post, patch, delete: del } = useApi()
const loadSites = async () => {
loading.value = true
try {
const result = await get('/sites')
console.log('sites api result', result)
if (result.success) {
const collection = Array.isArray(result.data)
? result.data
: Array.isArray(result.data?.member)
? result.data.member
: Array.isArray(result.data?.['hydra:member'])
? result.data['hydra:member']
: Array.isArray(result.data?.data)
? result.data.data
: []
sites.value = collection
showInfo(`Chargement de ${collection.length} site(s) réussi`)
}
} catch (error) {
console.error('Erreur lors du chargement des sites:', error)
} finally {
loading.value = false
}
}
const createSite = async (siteData) => {
loading.value = true
try {
const result = await post('/sites', siteData)
if (result.success) {
sites.value.push(result.data)
showSuccess(`Site "${siteData.name}" créé avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors de la création du site:', error)
return { success: false, error: error.message }
} finally {
loading.value = false
}
}
const updateSite = async (id, siteData) => {
loading.value = true
try {
const result = await patch(`/sites/${id}`, siteData)
if (result.success) {
const index = sites.value.findIndex(site => site.id === id)
if (index !== -1) {
sites.value[index] = result.data
}
showSuccess(`Site "${siteData.name}" mis à jour avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors de la mise à jour du site:', error)
return { success: false, error: error.message }
} finally {
loading.value = false
}
}
const deleteSite = async (id) => {
loading.value = true
try {
const result = await del(`/sites/${id}`)
if (result.success) {
const deletedSite = sites.value.find(site => site.id === id)
sites.value = sites.value.filter(site => site.id !== id)
showSuccess(`Site "${deletedSite?.name || 'inconnu'}" supprimé avec succès`)
}
return result
} catch (error) {
console.error('Erreur lors de la suppression du site:', error)
return { success: false, error: error.message }
} finally {
loading.value = false
}
}
const getSiteById = (id) => {
return sites.value.find(site => site.id === id)
}
const getSites = () => sites.value
const isLoading = () => loading.value
return {
sites,
loading,
loadSites,
createSite,
updateSite,
deleteSite,
getSiteById,
getSites,
isLoading
}
}

123
app/composables/useSites.ts Normal file
View File

@@ -0,0 +1,123 @@
import { ref } from 'vue'
import { useToast } from './useToast'
import { useApi } from './useApi'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Site {
id: string
name?: string
contactName?: string
contactPhone?: string
contactAddress?: string
contactPostalCode?: string
contactCity?: string
machines?: unknown[]
documents?: unknown[]
[key: string]: unknown
}
interface SiteResult {
success: boolean
data?: Site
error?: string
}
const sites = ref<Site[]>([])
const loading = ref(false)
const loaded = ref(false)
export function useSites() {
const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi()
const loadSites = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true
try {
const result = await get('/sites')
if (result.success) {
const collection = extractCollection(result.data)
sites.value = collection
loaded.value = true
}
} catch (error) {
console.error('Erreur lors du chargement des sites:', error)
} finally {
loading.value = false
}
}
const createSite = async (siteData: Partial<Site>): Promise<SiteResult> => {
loading.value = true
try {
const result = await post('/sites', siteData)
if (result.success && result.data) {
sites.value.push(result.data as Site)
showSuccess(`Site "${siteData.name}" créé avec succès`)
}
return result as SiteResult
} catch (error) {
console.error('Erreur lors de la création du site:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const updateSite = async (id: string, siteData: Partial<Site>): Promise<SiteResult> => {
loading.value = true
try {
const result = await patch(`/sites/${id}`, siteData)
if (result.success && result.data) {
const index = sites.value.findIndex((site) => site.id === id)
if (index !== -1) {
sites.value[index] = result.data as Site
}
showSuccess(`Site "${siteData.name}" mis à jour avec succès`)
}
return result as SiteResult
} catch (error) {
console.error('Erreur lors de la mise à jour du site:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const deleteSite = async (id: string): Promise<SiteResult> => {
loading.value = true
try {
const result = await del(`/sites/${id}`)
if (result.success) {
const deletedSite = sites.value.find((site) => site.id === id)
sites.value = sites.value.filter((site) => site.id !== id)
showSuccess(`Site "${deletedSite?.name || 'inconnu'}" supprimé avec succès`)
}
return result as SiteResult
} catch (error) {
console.error('Erreur lors de la suppression du site:', error)
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
const getSiteById = (id: string): Site | undefined => {
return sites.value.find((site) => site.id === id)
}
const getSites = () => sites.value
const isLoading = () => loading.value
return {
sites,
loading,
loadSites,
createSite,
updateSite,
deleteSite,
getSiteById,
getSites,
isLoading,
}
}

View File

@@ -1,17 +1,26 @@
import { ref } from 'vue' import { ref } from 'vue'
const toasts = ref([]) export type ToastType = 'success' | 'error' | 'warning' | 'info'
export interface Toast {
id: number
message: string
type: ToastType
visible: boolean
}
const toasts = ref<Toast[]>([])
const MAX_TOASTS = 3 const MAX_TOASTS = 3
let nextId = 1 let nextId = 1
export function useToast () { export function useToast() {
const showToast = (message, type = 'info', duration = 3500) => { const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
const id = nextId++ const id = nextId++
const toast = { const toast: Toast = {
id, id,
message, message,
type, type,
visible: true visible: true,
} }
if (toasts.value.length >= MAX_TOASTS) { if (toasts.value.length >= MAX_TOASTS) {
@@ -28,25 +37,25 @@ export function useToast () {
return id return id
} }
const showSuccess = (message, duration = 5000) => { const showSuccess = (message: string, duration = 5000): number => {
return showToast(message, 'success', duration) return showToast(message, 'success', duration)
} }
const showError = (message, duration = 5000) => { const showError = (message: string, duration = 5000): number => {
return showToast(message, 'error', duration) return showToast(message, 'error', duration)
} }
const showWarning = (message, duration = 6000) => { const showWarning = (message: string, duration = 6000): number => {
return showToast(message, 'warning', duration) return showToast(message, 'warning', duration)
} }
const showInfo = (message, duration = 5000) => { const showInfo = (message: string, duration = 5000): number => {
return showToast(message, 'info', duration) return showToast(message, 'info', duration)
} }
const removeToast = (id) => { const removeToast = (id: number): void => {
const index = toasts.value.findIndex(toast => toast.id === id) const index = toasts.value.findIndex((toast) => toast.id === id)
if (index !== -1) { if (index !== -1 && toasts.value[index]) {
toasts.value[index].visible = false toasts.value[index].visible = false
setTimeout(() => { setTimeout(() => {
toasts.value.splice(index, 1) toasts.value.splice(index, 1)
@@ -54,7 +63,7 @@ export function useToast () {
} }
} }
const clearAll = () => { const clearAll = (): void => {
toasts.value = [] toasts.value = []
} }
@@ -66,6 +75,6 @@ export function useToast () {
showWarning, showWarning,
showInfo, showInfo,
removeToast, removeToast,
clearAll clearAll,
} }
} }

274
app/pages/activity-log.vue Normal file
View File

@@ -0,0 +1,274 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header>
<h1 class="text-3xl font-semibold text-base-content">Journal d'activité</h1>
<p class="text-sm text-gray-500">
Historique des modifications sur l'ensemble des pièces, produits et composants.
</p>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<div 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">
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-entity-type"
>
Type
</label>
<select
id="activity-entity-type"
v-model="entityTypeFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option value="">Tous</option>
<option value="piece">Pièce</option>
<option value="product">Produit</option>
<option value="composant">Composant</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-action"
>
Action
</label>
<select
id="activity-action"
v-model="actionFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option value="">Toutes</option>
<option value="create">Création</option>
<option value="update">Modification</option>
<option value="delete">Suppression</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-per-page"
>
Par page
</label>
<select
id="activity-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div>
<p class="text-xs text-base-content/50 lg:text-right">
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
</p>
</div>
<div v-if="loading" class="flex justify-center py-8">
<span class="loading loading-spinner" aria-hidden="true" />
</div>
<p v-else-if="!total" class="text-sm text-base-content/70">
Aucune activité enregistrée.
</p>
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
Aucune activité ne correspond à vos filtres.
</p>
<template v-else>
<div class="overflow-x-auto">
<table class="table table-sm md:table-md">
<thead>
<tr>
<th>Date</th>
<th>Action</th>
<th>Type</th>
<th>Entité</th>
<th>Auteur</th>
<th>Détails</th>
</tr>
</thead>
<tbody>
<template v-for="entry in entries" :key="entry.id">
<tr>
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
<td>
<span
class="badge badge-sm"
:class="actionBadgeClass(entry.action)"
>
{{ historyActionLabel(entry.action) }}
</span>
</td>
<td>
<span class="badge badge-ghost badge-sm">
{{ entityTypeLabel(entry.entityType) }}
</span>
</td>
<td>
<NuxtLink
v-if="entry.action !== 'delete'"
:to="entityEditLink(entry)"
class="link link-hover link-primary"
>
{{ entry.entityName || 'Sans nom' }}
</NuxtLink>
<span v-else class="text-base-content/50 line-through">
{{ entry.entityName || 'Sans nom' }}
</span>
<span
v-if="entry.entityRef"
class="text-xs text-base-content/50 ml-1"
>
({{ entry.entityRef }})
</span>
</td>
<td>{{ entry.actor?.label || '—' }}</td>
<td>
<button
v-if="hasDiff(entry)"
type="button"
class="btn btn-ghost btn-xs"
@click="toggleExpanded(entry.id)"
>
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
</button>
<span v-else class="text-xs text-base-content/50"></span>
</td>
</tr>
<tr v-if="expandedIds.has(entry.id)">
<td colspan="6" class="bg-base-200/50 p-4">
<div class="space-y-1 text-sm">
<div
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
:key="diffEntry.field"
class="flex gap-2"
>
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
<span></span>
<span class="text-success">{{ diffEntry.toLabel }}</span>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useActivityLog } from '~/composables/useActivityLog'
import type { ActivityLogEntry } from '~/composables/useActivityLog'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
import Pagination from '~/components/common/Pagination.vue'
const { entries, total, loading, loadActivityLog } = useActivityLog()
const currentPage = ref(1)
const itemsPerPage = ref(50)
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
const entityTypeFilter = ref('')
const actionFilter = ref('')
const expandedIds = reactive(new Set<string>())
const toggleExpanded = (id: string) => {
if (expandedIds.has(id)) expandedIds.delete(id)
else expandedIds.add(id)
}
const hasDiff = (entry: ActivityLogEntry) =>
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
const fetchLog = () => {
loadActivityLog({
page: currentPage.value,
itemsPerPage: itemsPerPage.value,
entityType: entityTypeFilter.value || undefined,
action: actionFilter.value || undefined,
})
}
const handleFilterChange = () => {
currentPage.value = 1
fetchLog()
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchLog()
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
piece: 'Pièce',
product: 'Produit',
composant: 'Composant',
}
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
const ENTITY_EDIT_ROUTES: Record<string, string> = {
piece: '/pieces',
product: '/product',
composant: '/component',
}
const entityEditLink = (entry: ActivityLogEntry) => {
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
return base ? `${base}/${entry.entityId}/edit` : '#'
}
const actionBadgeClass = (action: string) => {
if (action === 'create') return 'badge-success'
if (action === 'delete') return 'badge-error'
return 'badge-warning'
}
const globalFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
supplierPrice: 'Prix fournisseur',
typePiece: 'Type de pièce',
typeProduct: 'Type de produit',
typeComposant: 'Type de composant',
product: 'Produit',
productIds: 'Produits',
constructeurIds: 'Fournisseurs',
structure: 'Structure',
}
onMounted(fetchLog)
</script>

View File

@@ -130,7 +130,16 @@
</td> </td>
<td>{{ component.name || 'Composant sans nom' }}</td> <td>{{ component.name || 'Composant sans nom' }}</td>
<td>{{ component.reference || '—' }}</td> <td>{{ component.reference || '—' }}</td>
<td>{{ resolveComponentType(component) }}</td> <td>
<NuxtLink
v-if="component.typeComposant?.id"
:to="`/component-category/${component.typeComposant.id}/edit`"
class="link link-hover link-primary"
>
{{ resolveComponentType(component) }}
</NuxtLink>
<span v-else>{{ resolveComponentType(component) }}</span>
</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink
@@ -327,7 +336,8 @@ const handleDeleteComponent = async (component: Record<string, any>) => {
) )
} }
const confirmed = window.confirm(confirmLines.join('\n\n')) const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) { if (!confirmed) {
return return
} }

View File

@@ -26,6 +26,10 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -38,16 +42,37 @@ import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports' import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue' import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes' import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const { loadComponentTypes } = useComponentTypes()
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null) const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/composants',
filterKey: 'typeComposant',
labels: {
singular: 'composant',
plural: 'composants',
verifying: 'Vérification des composants liés en cours…',
},
})
const title = computed(() => const title = computed(() =>
initialData.value?.name initialData.value?.name
? `Modifier « ${initialData.value.name} »` ? `Modifier « ${initialData.value.name} »`
@@ -86,8 +111,10 @@ const loadCategory = async () => {
code: response.code, code: response.code,
category: response.category, category: response.category,
notes: response.notes ?? response.description ?? '', notes: response.notes ?? response.description ?? '',
structure: response.structure ?? undefined, structure: (response.structure as ComponentModelStructure | null) ?? undefined,
} }
await loadLinkedCount(id)
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
await navigateBackToList() await navigateBackToList()
@@ -101,6 +128,9 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id) const id = String(route.params.id)
saving.value = true saving.value = true
try { try {
@@ -109,6 +139,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
description: payload?.notes ?? null, description: payload?.notes ?? null,
} }
await updateModelType(id, enrichedPayload) await updateModelType(id, enrichedPayload)
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.') showSuccess('Catégorie de composant mise à jour avec succès.')
await navigateBackToList() await navigateBackToList()
} catch (error) { } catch (error) {

View File

@@ -32,6 +32,7 @@ import { ref } from 'vue'
import { useHead, useRouter } from '#imports' import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue' import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes' import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
useHead(() => ({ useHead(() => ({
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
description: payload.notes ?? null, description: payload.notes ?? null,
} }
await createModelType(enrichedPayload) await createModelType(enrichedPayload)
invalidateEntityTypeCache('COMPONENT')
showSuccess('Catégorie de composant créée avec succès.') showSuccess('Catégorie de composant créée avec succès.')
await router.push('/component-category') await router.push('/component-category')
} catch (error: any) { } catch (error: any) {

View File

@@ -434,6 +434,74 @@
</p> </p>
</div> </div>
<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="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.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 historyEntries"
: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="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(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>
<div class="flex flex-col gap-3 md:flex-row md:justify-end"> <div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }"> <NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler Annuler
@@ -466,30 +534,38 @@ import { useToast } from '~/composables/useToast'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ComponentModelStructure } from '~/shared/types/inventory' import type { ComponentModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
import { getFileIcon } from '~/utils/fileIcons' import { canPreviewDocument } from '~/utils/documentPreview'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview' import {
type CustomFieldInput,
fieldKey,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import {
documentIcon,
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
interface ComponentCatalogType extends ModelType { interface ComponentCatalogType extends ModelType {
structure: ComponentModelStructure | null structure: ComponentModelStructure | null
customFields?: Array<Record<string, any>> customFields?: Array<Record<string, any>>
} }
interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { get } = useApi() const { get } = useApi()
@@ -500,9 +576,15 @@ const { updateComposant, loadComposants, composants: componentCatalogRef } = use
const { pieces, loadPieces } = usePieces() const { pieces, loadPieces } = usePieces()
const { products, loadProducts } = useProducts() const { products, loadProducts } = useProducts()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast() const toast = useToast()
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments() const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useComponentHistory()
const component = ref<any | null>(null) const component = ref<any | null>(null)
const loading = ref(true) const loading = ref(true)
@@ -513,6 +595,21 @@ const loadingDocuments = ref(false)
const componentDocuments = ref<any[]>([]) const componentDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null) const previewDocument = ref<any | null>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
structure: 'Structure',
typeComposant: 'Catégorie',
product: 'Produit lié',
constructeurIds: 'Fournisseurs',
}
const historyDiffEntries = (entry: ComponentHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const selectedTypeId = ref<string>('') const selectedTypeId = ref<string>('')
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,
@@ -522,49 +619,6 @@ const editionForm = reactive({
}) })
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
const documentIcon = (doc: any) =>
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
const formatSize = (size: number | null | undefined) => {
if (size === null || size === undefined) {
return '—'
}
if (size === 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
const shouldInlinePdf = (document: any) => {
if (!document || !isPdfDocument(document) || !document.path) {
return false
}
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
return false
}
return true
}
const appendPdfViewerParams = (src: string) => {
if (!src || src.startsWith('data:')) {
return src || ''
}
if (src.includes('#')) {
return `${src}&toolbar=0&navpanes=0`
}
return `${src}#toolbar=0&navpanes=0`
}
const documentPreviewSrc = (document: any) => {
if (!document?.path) {
return ''
}
if (isPdfDocument(document)) {
return appendPdfViewerParams(document.path)
}
return document.path
}
const fetchedPieceTypeMap = ref<Record<string, string>>({}) const fetchedPieceTypeMap = ref<Record<string, string>>({})
const pieceTypeLabelMap = computed(() => ({ const pieceTypeLabelMap = computed(() => ({
...Object.fromEntries( ...Object.fromEntries(
@@ -604,12 +658,6 @@ const componentCatalogMap = computed(() =>
.map((item: any) => [String(item.id), item]), .map((item: any) => [String(item.id), item]),
), ),
) )
const documentThumbnailClass = (document: any) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return 'h-24 w-20'
}
return 'h-16 w-16'
}
const openPreview = (doc: any) => { const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) { if (!doc || !canPreviewDocument(doc)) {
return return
@@ -621,20 +669,6 @@ const closePreview = () => {
previewVisible.value = false previewVisible.value = false
previewDocument.value = null previewDocument.value = null
} }
const downloadDocument = (doc: any) => {
if (!doc?.path) {
return
}
const target = String(doc.path)
if (target.startsWith('data:')) {
const link = document.createElement('a')
link.href = target
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(target, '_blank')
}
const removeDocument = async (documentId: string | number | null | undefined) => { const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) { if (!documentId) {
return return
@@ -674,7 +708,7 @@ const refreshDocuments = async () => {
try { try {
const result = await loadDocumentsByComponent(component.value.id, { updateStore: false }) const result = await loadDocumentsByComponent(component.value.id, { updateStore: false })
if (result.success) { if (result.success) {
componentDocuments.value = result.data || [] componentDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
} }
} finally { } finally {
loadingDocuments.value = false loadingDocuments.value = false
@@ -708,15 +742,7 @@ const refreshCustomFieldInputs = (
} }
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => { _requiredCustomFieldsFilled(customFieldInputs.value),
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return toFieldString(field.value).trim() !== ''
}),
) )
const canSubmit = computed(() => Boolean( const canSubmit = computed(() => Boolean(
@@ -726,19 +752,6 @@ const canSubmit = computed(() => Boolean(
!saving.value, !saving.value,
)) ))
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
const fetchComponent = async () => { const fetchComponent = async () => {
const id = route.params.id const id = route.params.id
if (!id || typeof id !== 'string') { if (!id || typeof id !== 'string') {
@@ -751,11 +764,10 @@ const fetchComponent = async () => {
component.value = result.data component.value = result.data
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = await getCustomFieldValuesByEntity('composant', result.data.id) const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
if (customValues.success && Array.isArray(customValues.data)) { refreshCustomFieldInputs(undefined, customValues)
component.value.customFieldValues = customValues.data
refreshCustomFieldInputs(undefined, customValues.data) loadHistory(result.data.id).catch(() => {})
}
} else { } else {
component.value = null component.value = null
componentDocuments.value = [] componentDocuments.value = []
@@ -791,7 +803,9 @@ watch(
void ensureConstructeurs(editionForm.constructeurIds) void ensureConstructeurs(editionForm.constructeurIds)
} }
refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues) // After setting selectedTypeId, read selectedTypeStructure.value (now updated) instead of
// the stale destructured currentStructure which was captured before the ID change.
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
initialized = true initialized = true
}, },
@@ -836,9 +850,17 @@ const submitEdition = async () => {
saving.value = true saving.value = true
try { try {
const result = await updateComposant(component.value.id, payload) const result = await updateComposant(component.value.id, payload)
if (result.success) { if (result.success && result.data) {
const updatedComponent = result.data const updatedComponent = result.data as Record<string, any>
await saveCustomFieldValues(updatedComponent) await _saveCustomFieldValues(
'composant',
updatedComponent.id,
[
updatedComponent?.typeComposant?.customFields,
updatedComponent?.typeMachineComponentRequirement?.typeComposant?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await router.push('/component-catalog') await router.push('/component-catalog')
} }
} catch (error: any) { } catch (error: any) {
@@ -848,260 +870,6 @@ const submitEdition = async () => {
} }
} }
const buildCustomFieldInputs = (
structure: ComponentModelStructure | null,
values: any[] | null,
): CustomFieldInput[] => {
const normalizedStructure = structure ? normalizeStructureForEditor(structure) : null
const definitions = normalizeCustomFieldInputs(normalizedStructure)
const valueList = Array.isArray(values) ? values : []
const mapById = new Map<string, any>()
const mapByName = new Map<string, any>()
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return
}
const fieldId = entry.customField?.id || entry.customFieldId || null
if (fieldId) {
mapById.set(fieldId, entry)
}
const fieldName = entry.customField?.name || entry.name || entry.key || null
if (fieldName) {
mapByName.set(fieldName, entry)
}
})
const resolved: CustomFieldInput[] = definitions.map((definition) => {
const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
if (!matched) {
return {
...definition,
customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null,
orderIndex: definition.orderIndex,
}
}
const resolvedValue = extractStoredCustomFieldValue(matched)
return {
...definition,
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null,
value: formatDefaultValue(definition.type, resolvedValue),
orderIndex: Math.min(
definition.orderIndex ?? 0,
typeof matched.customField?.orderIndex === 'number'
? matched.customField.orderIndex
: definition.orderIndex ?? 0,
),
}
}).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
return resolved
}
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldValueId || field.id || `${field.name}-${index}`
const normalizeCustomFieldInputs = (structure: ComponentModelStructure | null): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') {
return []
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') {
return null
}
const name = resolveFieldName(rawField)
if (!name) {
return null
}
const type = resolveFieldType(rawField)
const required = resolveRequiredFlag(rawField)
const options = resolveOptions(rawField)
const defaultSource = resolveDefaultValue(rawField)
const value = formatDefaultValue(type, defaultSource)
const id = typeof rawField.id === 'string' ? rawField.id : null
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
? rawField.customFieldValueId
: null
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
}
const resolveFieldName = (field: any): string => {
if (typeof field?.name === 'string' && field.name.trim()) {
return field.name.trim()
}
if (typeof field?.key === 'string' && field.key.trim()) {
return field.key.trim()
}
if (typeof field?.label === 'string' && field.label.trim()) {
return field.label.trim()
}
return ''
}
const resolveFieldType = (field: any): string => {
const allowed = ['text', 'number', 'select', 'boolean', 'date']
const rawType =
typeof field?.type === 'string'
? field.type
: typeof field?.value?.type === 'string'
? field.value.type
: ''
const value = rawType.toLowerCase()
return allowed.includes(value) ? value : 'text'
}
const resolveDefaultValue = (field: any): any => {
if (!field || typeof field !== 'object') {
return null
}
if (field.defaultValue !== undefined && field.defaultValue !== null) {
return field.defaultValue
}
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') {
return field.value
}
if (field.default !== undefined && field.default !== null) {
return field.default
}
if (field.value && typeof field.value === 'object') {
if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) {
return (field.value as any).defaultValue
}
if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') {
return (field.value as any).value
}
}
return null
}
const formatDefaultValue = (type: string, defaultValue: any): string => {
if (defaultValue === null || defaultValue === undefined) {
return ''
}
if (typeof defaultValue === 'object') {
if (defaultValue === null) {
return ''
}
if ('defaultValue' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
}
if ('value' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
}
return ''
}
if (type === 'boolean') {
const normalized = String(defaultValue).toLowerCase()
if (normalized === 'true' || normalized === '1') {
return 'true'
}
if (normalized === 'false' || normalized === '0') {
return 'false'
}
return ''
}
return String(defaultValue)
}
const resolveRequiredFlag = (field: any): boolean => {
if (typeof field?.required === 'boolean') {
return field.required
}
const nestedRequired = field?.value?.required
if (typeof nestedRequired === 'boolean') {
return nestedRequired
}
if (typeof nestedRequired === 'string') {
const normalized = nestedRequired.toLowerCase()
return normalized === 'true' || normalized === '1'
}
return false
}
const resolveOptions = (field: any): string[] => {
const sources = [field?.options, field?.value?.options, field?.value?.choices]
for (const source of sources) {
if (Array.isArray(source)) {
const mapped = source
.map((option: unknown) => {
if (option === null || option === undefined) {
return ''
}
if (typeof option === 'string') {
return option.trim()
}
if (typeof option === 'object') {
const record = option || {}
const keys = ['value', 'label', 'name']
for (const key of keys) {
const candidate = record[key]
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim()
}
}
}
const fallback = String(option).trim()
return fallback === '[object Object]' ? '' : fallback
})
.filter((option) => option.length > 0)
if (mapped.length) {
return mapped
}
}
}
return []
}
const extractStoredCustomFieldValue = (entry: any): any => {
if (entry === null || entry === undefined) {
return ''
}
if (typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean') {
return entry
}
if (typeof entry !== 'object') {
return String(entry)
}
const direct = entry.value
if (direct !== undefined && direct !== null) {
if (typeof direct === 'object') {
if (direct === null) {
return ''
}
if ('value' in direct && direct.value !== undefined && direct.value !== null) {
return direct.value
}
if ('defaultValue' in direct && direct.defaultValue !== undefined && direct.defaultValue !== null) {
return direct.defaultValue
}
return ''
}
return direct
}
if (entry.defaultValue !== undefined && entry.defaultValue !== null) {
return entry.defaultValue
}
if (entry.customFieldValue?.value !== undefined && entry.customFieldValue?.value !== null) {
return entry.customFieldValue.value
}
return ''
}
const getStructureCustomFields = (structure: ComponentModelStructure | null) => { const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.customFields) ? structure.customFields : [] return Array.isArray(structure?.customFields) ? structure.customFields : []
} }
@@ -1156,13 +924,14 @@ const fetchPieceTypeNames = async (ids: string[]) => {
) )
const next = { ...fetchedPieceTypeMap.value } const next = { ...fetchedPieceTypeMap.value }
results.forEach((result, index) => { results.forEach((result, index) => {
if (result.status !== 'fulfilled') { const key = missing[index]
if (!key || result.status !== 'fulfilled') {
return return
} }
const data = result.value?.data const data = result.value?.data
const name = data?.name || data?.code const name = data?.name || data?.code
if (name) { if (name) {
next[missing[index]] = name next[key] = name
} }
}) })
fetchedPieceTypeMap.value = next fetchedPieceTypeMap.value = next
@@ -1199,13 +968,14 @@ const fetchProductTypeNames = async (ids: string[]) => {
) )
const next = { ...fetchedProductTypeMap.value } const next = { ...fetchedProductTypeMap.value }
results.forEach((result, index) => { results.forEach((result, index) => {
if (result.status !== 'fulfilled') { const key = missing[index]
if (!key || result.status !== 'fulfilled') {
return return
} }
const data = result.value?.data const data = result.value?.data
const name = data?.name || data?.code const name = data?.name || data?.code
if (name) { if (name) {
next[missing[index]] = name next[key] = name
} }
}) })
fetchedProductTypeMap.value = next fetchedProductTypeMap.value = next
@@ -1355,117 +1125,20 @@ const structureSelections = computed(() => {
} }
}) })
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
})
const shouldPersistField = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return toFieldString(field.value).trim() !== ''
}
const formatValueForPersistence = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' ? 'true' : 'false'
}
return toFieldString(field.value).trim()
}
const saveCustomFieldValues = async (updatedComponent: any) => {
if (!updatedComponent || !updatedComponent.id) {
return
}
const definitionMap = new Map<string, string>()
const registerDefinitions = (fields: any[]) => {
if (!Array.isArray(fields)) {
return
}
fields.forEach((field) => {
if (!field || typeof field !== 'object') {
return
}
const name = typeof field.name === 'string' ? field.name : null
const id = typeof field.id === 'string' ? field.id : null
if (name && id && !definitionMap.has(name)) {
definitionMap.set(name, id)
}
})
}
registerDefinitions(updatedComponent?.typeComposant?.customFields)
registerDefinitions(updatedComponent?.typeMachineComponentRequirement?.typeComposant?.customFields)
const resolveDefinitionId = (field: CustomFieldInput) => {
if (field.customFieldId) {
return field.customFieldId
}
if (field.id) {
return field.id
}
return definitionMap.get(field.name) ?? null
}
for (const field of customFieldInputs.value) {
if (!shouldPersistField(field)) {
continue
}
const definitionId = resolveDefinitionId(field)
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
const value = formatValueForPersistence(field)
if (field.customFieldValueId) {
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
if (!result.success) {
toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
} else if (definitionId && !field.customFieldId) {
field.customFieldId = definitionId
}
continue
}
const result = await upsertCustomFieldValue(
definitionId,
'composant',
updatedComponent.id,
value,
metadata,
)
if (!result.success) {
toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
} else {
const createdValue = result.data
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
}
const resolvedId = createdValue?.customField?.id || definitionId
if (resolvedId) {
field.customFieldId = resolvedId
}
}
}
}
onMounted(async () => { onMounted(async () => {
await Promise.allSettled([ await Promise.allSettled([
loadComponentTypes(), loadComponentTypes(),
loadPieceTypes(), loadPieceTypes(),
loadProductTypes(), loadProductTypes(),
loadPieces({ itemsPerPage: 500 }),
loadProducts({ itemsPerPage: 500, force: true }),
loadComposants({ itemsPerPage: 500 }),
fetchComponent(), fetchComponent(),
]) ])
loading.value = false loading.value = false
if (component.value?.id) {
await refreshDocuments() // Defer bulk catalog loads — not needed for initial render
} Promise.allSettled([
loadPieces({ itemsPerPage: 500 }),
loadProducts({ itemsPerPage: 500 }),
loadComposants({ itemsPerPage: 500 }),
]).catch(() => {})
}) })
</script> </script>

View File

@@ -360,6 +360,10 @@ import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields' import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils' import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import {
toFieldString,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
} from '~/shared/utils/customFieldFormUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { import type {
ComponentModelPiece, ComponentModelPiece,
@@ -747,15 +751,7 @@ const serializeStructureAssignments = (
} }
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => { _requiredCustomFieldsFilled(customFieldInputs.value),
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return toFieldString(field.value).trim() !== ''
}),
) )
const canSubmit = computed(() => Boolean( const canSubmit = computed(() => Boolean(
@@ -766,19 +762,6 @@ const canSubmit = computed(() => Boolean(
!submitting.value, !submitting.value,
)) ))
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
const getStructureCustomFields = (structure: ComponentModelStructure | null) => { const getStructureCustomFields = (structure: ComponentModelStructure | null) => {
return Array.isArray(structure?.customFields) ? structure.customFields : [] return Array.isArray(structure?.customFields) ? structure.customFields : []
} }
@@ -830,13 +813,14 @@ const fetchPieceTypeNames = async (ids: string[]) => {
) )
const next = { ...fetchedPieceTypeMap.value } const next = { ...fetchedPieceTypeMap.value }
results.forEach((result, index) => { results.forEach((result, index) => {
if (result.status !== 'fulfilled') { const key = missing[index]
if (!key || result.status !== 'fulfilled') {
return return
} }
const data = result.value?.data const data = result.value?.data
const name = data?.name || data?.code const name = data?.name || data?.code
if (name) { if (name) {
next[missing[index]] = name next[key] = name
} }
}) })
fetchedPieceTypeMap.value = next fetchedPieceTypeMap.value = next
@@ -1161,7 +1145,7 @@ const resolveOptions = (field: any): string[] => {
return option.trim() return option.trim()
} }
if (typeof option === 'object') { if (typeof option === 'object') {
const record = option || {} const record = option as Record<string, unknown>
const keys = ['value', 'label', 'name'] const keys = ['value', 'label', 'name']
for (const key of keys) { for (const key of keys) {
const candidate = record[key] const candidate = record[key]

View File

@@ -127,7 +127,7 @@ import { formatPhone } from '~/utils/formatters/phone'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs() const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
const { showError, showSuccess } = useToast() const { showError } = useToast()
const searchTerm = ref('') const searchTerm = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name') const sortKey = usePersistedValue('constructeurs-sort', 'name')
@@ -211,8 +211,10 @@ const saveConstructeur = async () => {
} }
} }
const { confirm } = useConfirm()
const confirmDelete = async (constructeur) => { const confirmDelete = async (constructeur) => {
if (!confirm(`Supprimer le fournisseur "${constructeur.name}" ?`)) { return } if (!await confirm({ message: `Supprimer le fournisseur "${constructeur.name}" ?` })) { return }
const result = await deleteConstructeur(constructeur.id) const result = await deleteConstructeur(constructeur.id)
if (!result.success && result.error) { if (!result.success && result.error) {
showError(result.error) showError(result.error)

View File

@@ -705,13 +705,15 @@ const editMachine = (machine) => {
navigateTo(`/machine/${machine.id}?edit=true`) navigateTo(`/machine/${machine.id}?edit=true`)
} }
const { confirm: confirmDialog } = useConfirm()
const confirmDeleteMachine = async (machine) => { const confirmDeleteMachine = async (machine) => {
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
if ( if (
confirm( await confirmDialog({
`Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.` message: `Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.`,
) })
) { ) {
try { try {
const result = await deleteMachine(machine.id) const result = await deleteMachine(machine.id)

View File

@@ -108,7 +108,7 @@ import IconLucidePackage from "~icons/lucide/package";
import IconLucideLayoutGrid from "~icons/lucide/layout-grid"; import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
import IconLucideBox from "~icons/lucide/box"; import IconLucideBox from "~icons/lucide/box";
const { machineTypes, loading, loadMachineTypes, deleteMachineType } = const { machineTypes, loadMachineTypes, deleteMachineType } =
useMachineTypesApi(); useMachineTypesApi();
const categories = ref([ const categories = ref([
@@ -131,13 +131,15 @@ const filteredTypes = computed(() => {
); );
}); });
const { confirm: confirmDialog } = useConfirm();
const confirmDeleteType = async (type) => { const confirmDeleteType = async (type) => {
const { showError, showSuccess } = useToast(); const { showError, showSuccess } = useToast();
if ( if (
confirm( await confirmDialog({
`Êtes-vous sûr de vouloir supprimer le type "${type.name}" ? Cette action est irréversible.` message: `Êtes-vous sûr de vouloir supprimer le type "${type.name}" ? Cette action est irréversible.`,
) })
) { ) {
try { try {
const result = await deleteMachineType(type.id); const result = await deleteMachineType(type.id);

File diff suppressed because it is too large Load Diff

View File

@@ -202,10 +202,12 @@ const editMachine = (machine) => {
navigateTo(`/machine/${machine.id}?edit=true`) navigateTo(`/machine/${machine.id}?edit=true`)
} }
const { confirm: confirmDialog } = useConfirm()
const confirmDeleteMachine = async (machine) => { const confirmDeleteMachine = async (machine) => {
const { showError, showSuccess } = toast const { showError, showSuccess } = toast
if (confirm(`Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.`)) { if (await confirmDialog({ message: `Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.` })) {
try { try {
const result = await deleteMachine(machine.id) const result = await deleteMachine(machine.id)
if (result.success) { if (result.success) {

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,10 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -38,16 +42,37 @@ import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports' import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue' import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes' import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import type { PieceModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const { loadPieceTypes } = usePieceTypes()
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null) const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/pieces',
filterKey: 'typePiece',
labels: {
singular: 'pièce',
plural: 'pièces',
verifying: 'Vérification des pièces liées en cours…',
},
})
const title = computed(() => const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce', initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
) )
@@ -84,8 +109,10 @@ const loadCategory = async () => {
code: response.code, code: response.code,
category: response.category, category: response.category,
notes: response.notes ?? response.description ?? '', notes: response.notes ?? response.description ?? '',
structure: response.structure ?? undefined, structure: (response.structure as PieceModelStructure | null) ?? undefined,
} }
await loadLinkedCount(id)
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
await navigateBackToList() await navigateBackToList()
@@ -99,6 +126,9 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id) const id = String(route.params.id)
saving.value = true saving.value = true
try { try {
@@ -107,6 +137,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
description: payload?.notes ?? null, description: payload?.notes ?? null,
} }
await updateModelType(id, enrichedPayload) await updateModelType(id, enrichedPayload)
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.') showSuccess('Catégorie de pièce mise à jour avec succès.')
await navigateBackToList() await navigateBackToList()
} catch (error) { } catch (error) {

View File

@@ -32,6 +32,7 @@ import { ref } from 'vue'
import { useHead, useRouter } from '#imports' import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue' import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes' import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
useHead(() => ({ useHead(() => ({
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
description: payload.notes ?? null, description: payload.notes ?? null,
} }
await createModelType(enrichedPayload) await createModelType(enrichedPayload)
invalidateEntityTypeCache('PIECE')
showSuccess('Catégorie de pièce créée avec succès.') showSuccess('Catégorie de pièce créée avec succès.')
await router.push('/piece-category') await router.push('/piece-category')
} catch (error: any) { } catch (error: any) {

View File

@@ -152,7 +152,16 @@
</div> </div>
<span v-else></span> <span v-else></span>
</td> </td>
<td>{{ resolvePieceType(row.piece) }}</td> <td>
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NuxtLink <NuxtLink
@@ -189,7 +198,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import { usePieces } from '~/composables/usePieces' import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
@@ -442,7 +451,8 @@ const handleDeletePiece = async (piece: Record<string, any>) => {
) )
} }
const confirmed = window.confirm(confirmLines.join('\n\n')) const { confirm } = useConfirm()
const confirmed = await confirm({ message: confirmLines.join('\n\n') })
if (!confirmed) { if (!confirmed) {
return return
} }

View File

@@ -381,6 +381,74 @@
</p> </p>
</div> </div>
<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="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.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 historyEntries"
: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="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(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>
<div class="flex flex-col gap-3 md:flex-row md:justify-end"> <div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }"> <NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler Annuler
@@ -409,41 +477,55 @@ import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
import { extractRelationId } from '~/shared/apiRelations' import { extractRelationId } from '~/shared/apiRelations'
import { getFileIcon } from '~/utils/fileIcons' import { canPreviewDocument } from '~/utils/documentPreview'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils' import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory' import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
import { getModelType } from '~/services/modelTypes' import { getModelType } from '~/services/modelTypes'
import {
type CustomFieldInput,
fieldKey,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
import {
documentIcon,
formatSize,
shouldInlinePdf,
documentPreviewSrc,
documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
interface PieceCatalogType extends ModelType { interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null structure: PieceModelStructure | null
customFields?: Array<Record<string, any>> customFields?: Array<Record<string, any>>
} }
interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { get } = useApi() const { get } = useApi()
const { pieceTypes, loadPieceTypes } = usePieceTypes() const { pieceTypes, loadPieceTypes } = usePieceTypes()
const { updatePiece } = usePieces() const { updatePiece } = usePieces()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const toast = useToast() const toast = useToast()
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments() const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = usePieceHistory()
const piece = ref<any | null>(null) const piece = ref<any | null>(null)
const loading = ref(true) const loading = ref(true)
@@ -455,6 +537,21 @@ const pieceDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null) const previewDocument = ref<any | null>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
typePiece: 'Catégorie',
product: 'Produit lié',
productIds: 'Produits liés',
constructeurIds: 'Fournisseurs',
}
const historyDiffEntries = (entry: PieceHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const selectedTypeId = ref<string>('') const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null) const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({ const editionForm = reactive({
@@ -466,8 +563,6 @@ const editionForm = reactive({
const productSelections = ref<(string | null)[]>([]) const productSelections = ref<(string | null)[]>([])
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
const documentIcon = (doc: any) =>
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
const resolvedStructure = computed<PieceModelStructure | null>(() => const resolvedStructure = computed<PieceModelStructure | null>(() =>
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null, pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
) )
@@ -480,52 +575,6 @@ const refreshCustomFieldInputs = (
const values = valuesOverride ?? piece.value?.customFieldValues ?? null const values = valuesOverride ?? piece.value?.customFieldValues ?? null
customFieldInputs.value = buildCustomFieldInputs(structure, values) customFieldInputs.value = buildCustomFieldInputs(structure, values)
} }
const formatSize = (size: number | null | undefined) => {
if (size === null || size === undefined) {
return '—'
}
if (size === 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
const shouldInlinePdf = (document: any) => {
if (!document || !isPdfDocument(document) || !document.path) {
return false
}
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
return false
}
return true
}
const appendPdfViewerParams = (src: string) => {
if (!src || src.startsWith('data:')) {
return src || ''
}
if (src.includes('#')) {
return `${src}&toolbar=0&navpanes=0`
}
return `${src}#toolbar=0&navpanes=0`
}
const documentPreviewSrc = (document: any) => {
if (!document?.path) {
return ''
}
if (isPdfDocument(document)) {
return appendPdfViewerParams(document.path)
}
return document.path
}
const documentThumbnailClass = (document: any) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return 'h-24 w-20'
}
return 'h-16 w-16'
}
const openPreview = (doc: any) => { const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) { if (!doc || !canPreviewDocument(doc)) {
return return
@@ -537,20 +586,6 @@ const closePreview = () => {
previewVisible.value = false previewVisible.value = false
previewDocument.value = null previewDocument.value = null
} }
const downloadDocument = (doc: any) => {
if (!doc?.path) {
return
}
const target = String(doc.path)
if (target.startsWith('data:')) {
const link = document.createElement('a')
link.href = target
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(target, '_blank')
}
const removeDocument = async (documentId: string | number | null | undefined) => { const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) { if (!documentId) {
return return
@@ -590,7 +625,7 @@ const refreshDocuments = async () => {
try { try {
const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false }) const result = await loadDocumentsByPiece(piece.value.id, { updateStore: false })
if (result.success) { if (result.success) {
pieceDocuments.value = result.data || [] pieceDocuments.value = Array.isArray(result.data) ? result.data : result.data ? [result.data] : []
} }
} finally { } finally {
loadingDocuments.value = false loadingDocuments.value = false
@@ -691,15 +726,7 @@ watch(structureProducts, (products) => {
}) })
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => { _requiredCustomFieldsFilled(customFieldInputs.value),
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return toFieldString(field.value).trim() !== ''
}),
) )
const canSubmit = computed(() => const canSubmit = computed(() =>
@@ -712,19 +739,6 @@ const canSubmit = computed(() =>
), ),
) )
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
const fetchPiece = async () => { const fetchPiece = async () => {
const id = route.params.id const id = route.params.id
if (!id || typeof id !== 'string') { if (!id || typeof id !== 'string') {
@@ -736,19 +750,23 @@ const fetchPiece = async () => {
if (result.success) { if (result.success) {
piece.value = result.data piece.value = result.data
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
const customValues = await getCustomFieldValuesByEntity('piece', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) { // Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
piece.value.customFieldValues = customValues.data const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues.data) refreshCustomFieldInputs(undefined, customValues)
}
await loadPieceTypeDetails(result.data) // Use cached type from loadPieceTypes() instead of separate getModelType() call
loadPieceTypeDetailsFromCache(result.data)
// History is non-blocking — template handles its own loading state
loadHistory(result.data.id).catch(() => {})
} else { } else {
piece.value = null piece.value = null
pieceDocuments.value = [] pieceDocuments.value = []
} }
} }
const loadPieceTypeDetails = async (currentPiece: any) => { const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
const typeId = currentPiece?.typePieceId const typeId = currentPiece?.typePieceId
|| extractRelationId(currentPiece?.typePiece) || extractRelationId(currentPiece?.typePiece)
|| '' || ''
@@ -756,15 +774,22 @@ const loadPieceTypeDetails = async (currentPiece: any) => {
pieceTypeDetails.value = null pieceTypeDetails.value = null
return return
} }
try { // Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
const type = await getModelType(typeId) const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
if (cachedType) {
pieceTypeDetails.value = cachedType
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
return
}
// Fallback: fetch if not in cache (edge case)
getModelType(typeId).then((type) => {
if (type && typeof type === 'object') { if (type && typeof type === 'object') {
pieceTypeDetails.value = type pieceTypeDetails.value = type
refreshCustomFieldInputs(type.structure ?? null, currentPiece?.customFieldValues ?? null) refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
} }
} catch (error) { }).catch(() => {
pieceTypeDetails.value = null pieceTypeDetails.value = null
} })
} }
let initialized = false let initialized = false
@@ -812,7 +837,10 @@ watch(
pendingProductIds = [] pendingProductIds = []
} }
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues) // After setting selectedTypeId, read selectedType.value (now updated) instead of
// the stale destructured currentType which was captured before the ID change.
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
initialized = true initialized = true
}, },
@@ -823,9 +851,7 @@ watch(selectedType, (currentType) => {
if (!piece.value || !currentType) { if (!piece.value || !currentType) {
return return
} }
if (!pieceTypeDetails.value) { refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
}
}) })
watch(resolvedStructure, (currentStructure) => { watch(resolvedStructure, (currentStructure) => {
@@ -882,9 +908,17 @@ const submitEdition = async () => {
saving.value = true saving.value = true
try { try {
const result = await updatePiece(piece.value.id, payload) const result = await updatePiece(piece.value.id, payload)
if (result.success) { if (result.success && result.data) {
const updatedPiece = result.data const updatedPiece = result.data as Record<string, any>
await saveCustomFieldValues(updatedPiece) await _saveCustomFieldValues(
'piece',
updatedPiece.id,
[
updatedPiece?.typePiece?.pieceCustomFields,
updatedPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
await router.push('/pieces-catalog') await router.push('/pieces-catalog')
} }
} catch (error: any) { } catch (error: any) {
@@ -894,276 +928,8 @@ const submitEdition = async () => {
} }
} }
const buildCustomFieldInputs = (
structure: PieceModelStructure | null,
values: any[] | null,
): CustomFieldInput[] => {
const definitions = normalizeCustomFieldInputs(structure)
const valueList = Array.isArray(values) ? values : []
const mapById = new Map<string, any>()
const mapByName = new Map<string, any>()
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return
}
const fieldId = entry.customField?.id || entry.customFieldId || null
if (fieldId) {
mapById.set(fieldId, entry)
}
const fieldName = entry.customField?.name || entry.name || entry.key || null
if (fieldName) {
mapByName.set(fieldName, entry)
}
})
const resolved: CustomFieldInput[] = definitions.map((definition) => {
const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? mapById.get(definitionId) : null) || mapByName.get(definition.name)
if (!matched) {
return {
...definition,
customFieldId: definition.customFieldId || definition.id,
customFieldValueId: null,
orderIndex: definition.orderIndex,
}
}
const resolvedValue = matched.value ?? ''
return {
...definition,
customFieldId: matched.customField?.id || definition.customFieldId || definition.id,
customFieldValueId: matched.id ?? null,
value: formatDefaultValue(definition.type, resolvedValue),
orderIndex: Math.min(
definition.orderIndex ?? 0,
typeof matched.customField?.orderIndex === 'number'
? matched.customField.orderIndex
: definition.orderIndex ?? 0,
),
}
}).sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
return resolved
}
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldValueId || field.id || `${field.name}-${index}`
const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') {
return []
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') {
return null
}
const name = resolveFieldName(rawField)
if (!name) {
return null
}
const type = resolveFieldType(rawField)
const required = !!rawField.required
const options = Array.isArray(rawField.options)
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
: []
const defaultSource = resolveDefaultValue(rawField)
const value = formatDefaultValue(type, defaultSource)
const id = typeof rawField.id === 'string' ? rawField.id : null
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
? rawField.customFieldValueId
: null
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
}
const resolveFieldName = (field: any): string => {
if (typeof field?.name === 'string' && field.name.trim()) {
return field.name.trim()
}
if (typeof field?.key === 'string' && field.key.trim()) {
return field.key.trim()
}
if (typeof field?.label === 'string' && field.label.trim()) {
return field.label.trim()
}
return ''
}
const resolveFieldType = (field: any): string => {
const allowed = ['text', 'number', 'select', 'boolean', 'date']
const value = typeof field?.type === 'string' ? field.type.toLowerCase() : ''
return allowed.includes(value) ? value : 'text'
}
const resolveDefaultValue = (field: any): any => {
if (!field || typeof field !== 'object') {
return null
}
if (field.defaultValue !== undefined && field.defaultValue !== null) {
return field.defaultValue
}
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') {
return field.value
}
if (field.default !== undefined && field.default !== null) {
return field.default
}
if (field.value && typeof field.value === 'object') {
if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) {
return (field.value as any).defaultValue
}
if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') {
return (field.value as any).value
}
}
return null
}
const formatDefaultValue = (type: string, defaultValue: any): string => {
if (defaultValue === null || defaultValue === undefined) {
return ''
}
if (typeof defaultValue === 'object') {
if (defaultValue === null) {
return ''
}
if ('defaultValue' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
}
if ('value' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
}
return ''
}
if (type === 'boolean') {
const normalized = String(defaultValue).toLowerCase()
if (normalized === 'true' || normalized === '1') {
return 'true'
}
if (normalized === 'false' || normalized === '0') {
return 'false'
}
return ''
}
return String(defaultValue)
}
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
})
const shouldPersistField = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return toFieldString(field.value).trim() !== ''
}
const formatValueForPersistence = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' ? 'true' : 'false'
}
return toFieldString(field.value).trim()
}
const saveCustomFieldValues = async (updatedPiece: any) => {
if (!updatedPiece || !updatedPiece.id) {
return
}
const definitionMap = new Map<string, string>()
const registerDefinitions = (fields: any[]) => {
if (!Array.isArray(fields)) {
return
}
fields.forEach((field) => {
if (!field || typeof field !== 'object') {
return
}
const name = typeof field.name === 'string' ? field.name : null
const id = typeof field.id === 'string' ? field.id : null
if (name && id && !definitionMap.has(name)) {
definitionMap.set(name, id)
}
})
}
registerDefinitions(updatedPiece?.typePiece?.pieceCustomFields)
registerDefinitions(updatedPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields)
const resolveDefinitionId = (field: CustomFieldInput) => {
if (field.customFieldId) {
return field.customFieldId
}
if (field.id) {
return field.id
}
return definitionMap.get(field.name) ?? null
}
for (const field of customFieldInputs.value) {
if (!shouldPersistField(field)) {
continue
}
const definitionId = resolveDefinitionId(field)
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
const value = formatValueForPersistence(field)
if (field.customFieldValueId) {
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
if (!result.success) {
toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
} else if (definitionId && !field.customFieldId) {
field.customFieldId = definitionId
}
continue
}
const result = await upsertCustomFieldValue(
definitionId,
'piece',
updatedPiece.id,
value,
metadata,
)
if (!result.success) {
toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
} else {
const createdValue = result.data
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
}
const resolvedId = createdValue?.customField?.id || definitionId
if (resolvedId) {
field.customFieldId = resolvedId
}
}
}
}
onMounted(async () => { onMounted(async () => {
await Promise.allSettled([loadPieceTypes(), fetchPiece()]) await Promise.allSettled([loadPieceTypes(), fetchPiece()])
loading.value = false loading.value = false
if (piece.value?.id) {
await refreshDocuments()
}
}) })
</script> </script>

View File

@@ -308,6 +308,13 @@ import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory' import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
import {
type CustomFieldInput,
fieldKey,
normalizeCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
interface PieceCatalogType extends ModelType { interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null structure: PieceModelStructure | null
@@ -466,15 +473,7 @@ watch(selectedType, (type) => {
}) })
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => { _requiredCustomFieldsFilled(customFieldInputs.value),
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return toFieldString(field.value).trim() !== ''
}),
) )
const canSubmit = computed(() => const canSubmit = computed(() =>
@@ -487,19 +486,6 @@ const canSubmit = computed(() =>
), ),
) )
const toFieldString = (value: unknown): string => {
if (value === null || value === undefined) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return ''
}
const clearCreationForm = () => { const clearCreationForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.reference = '' creationForm.reference = ''
@@ -557,14 +543,23 @@ const submitCreation = async () => {
submitting.value = true submitting.value = true
try { try {
const result = await createPiece(payload) const result = await createPiece(payload)
if (result.success) { if (result.success && result.data) {
await saveCustomFieldValues(result.data) const createdPiece = result.data as Record<string, any>
if (selectedDocuments.value.length && result.data?.id) { await _saveCustomFieldValues(
'piece',
createdPiece.id,
[
createdPiece?.typePiece?.pieceCustomFields,
createdPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
if (selectedDocuments.value.length && createdPiece.id) {
uploadingDocuments.value = true uploadingDocuments.value = true
const uploadResult = await uploadDocuments( const uploadResult = await uploadDocuments(
{ {
files: selectedDocuments.value, files: selectedDocuments.value,
context: { pieceId: result.data.id }, context: { pieceId: createdPiece.id },
}, },
{ updateStore: false }, { updateStore: false },
) )
@@ -593,225 +588,4 @@ onMounted(async () => {
await loadPieceTypes() await loadPieceTypes()
}) })
interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
customFieldValueId: string | null
orderIndex: number
}
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldValueId || field.id || `${field.name}-${index}`
const normalizeCustomFieldInputs = (structure: PieceModelStructure | null): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') {
return []
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') {
return null
}
const name = resolveFieldName(rawField)
if (!name) {
return null
}
const type = resolveFieldType(rawField)
const required = !!rawField.required
const options = Array.isArray(rawField.options)
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
: []
const defaultSource = resolveDefaultValue(rawField)
const value = formatDefaultValue(type, defaultSource)
const id = typeof rawField.id === 'string' ? rawField.id : null
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
const customFieldValueId = typeof rawField.customFieldValueId === 'string'
? rawField.customFieldValueId
: null
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, customFieldValueId, orderIndex }
}
const resolveFieldName = (field: any): string => {
if (typeof field?.name === 'string' && field.name.trim()) {
return field.name.trim()
}
if (typeof field?.key === 'string' && field.key.trim()) {
return field.key.trim()
}
if (typeof field?.label === 'string' && field.label.trim()) {
return field.label.trim()
}
return ''
}
const resolveFieldType = (field: any): string => {
const allowed = ['text', 'number', 'select', 'boolean', 'date']
const value = typeof field?.type === 'string' ? field.type.toLowerCase() : ''
return allowed.includes(value) ? value : 'text'
}
const resolveDefaultValue = (field: any): any => {
if (!field || typeof field !== 'object') {
return null
}
if (field.defaultValue !== undefined && field.defaultValue !== null) {
return field.defaultValue
}
if (field.value !== undefined && field.value !== null && typeof field.value !== 'object') {
return field.value
}
if (field.default !== undefined && field.default !== null) {
return field.default
}
if (field.value && typeof field.value === 'object') {
if ((field.value as any).defaultValue !== undefined && (field.value as any).defaultValue !== null) {
return (field.value as any).defaultValue
}
if ((field.value as any).value !== undefined && (field.value as any).value !== null && typeof (field.value as any).value !== 'object') {
return (field.value as any).value
}
}
return null
}
const formatDefaultValue = (type: string, defaultValue: any): string => {
if (defaultValue === null || defaultValue === undefined) {
return ''
}
if (typeof defaultValue === 'object') {
if (defaultValue === null) {
return ''
}
if ('defaultValue' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).defaultValue)
}
if ('value' in (defaultValue as Record<string, any>)) {
return formatDefaultValue(type, (defaultValue as Record<string, any>).value)
}
return ''
}
if (type === 'boolean') {
const normalized = String(defaultValue).toLowerCase()
if (normalized === 'true' || normalized === '1') {
return 'true'
}
if (normalized === 'false' || normalized === '0') {
return 'false'
}
return ''
}
return String(defaultValue)
}
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
customFieldName: field.name,
customFieldType: field.type,
customFieldRequired: field.required,
customFieldOptions: field.options,
})
const saveCustomFieldValues = async (createdPiece: any) => {
if (!createdPiece || !createdPiece.id) {
return
}
const definitionMap = new Map<string, string>()
const registerDefinitions = (fields: any[]) => {
if (!Array.isArray(fields)) {
return
}
fields.forEach((field) => {
if (!field || typeof field !== 'object') {
return
}
const name = typeof field.name === 'string' ? field.name : null
const id = typeof field.id === 'string' ? field.id : null
if (name && id && !definitionMap.has(name)) {
definitionMap.set(name, id)
}
})
}
registerDefinitions(createdPiece?.typePiece?.pieceCustomFields)
registerDefinitions(createdPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields)
const customizeFieldId = (field: CustomFieldInput) => {
if (field.customFieldId) {
return field.customFieldId
}
if (field.id) {
return field.id
}
return definitionMap.get(field.name) ?? null
}
for (const field of customFieldInputs.value) {
if (!shouldPersistField(field)) {
continue
}
const definitionId = customizeFieldId(field)
const metadata = definitionId ? undefined : buildCustomFieldMetadata(field)
const value = formatValueForPersistence(field)
if (field.customFieldValueId) {
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
if (!result.success) {
toast.showError(`Impossible de mettre à jour le champ personnalisé "${field.name}"`)
} else if (definitionId && !field.customFieldId) {
field.customFieldId = definitionId
}
continue
}
const result = await upsertCustomFieldValue(
definitionId,
'piece',
createdPiece.id,
value,
metadata,
)
if (!result.success) {
toast.showError(`Impossible d'enregistrer le champ personnalisé "${field.name}"`)
} else {
const createdValue = result.data
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
}
const resolvedId = createdValue?.customField?.id || definitionId
if (resolvedId) {
field.customFieldId = resolvedId
}
}
}
}
const shouldPersistField = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return toFieldString(field.value).trim() !== ''
}
const formatValueForPersistence = (field: CustomFieldInput) => {
if (field.type === 'boolean') {
return field.value === 'true' ? 'true' : 'false'
}
return toFieldString(field.value).trim()
}
</script> </script>

View File

@@ -110,7 +110,16 @@
</td> </td>
<td class="font-medium">{{ row.product.name }}</td> <td class="font-medium">{{ row.product.name }}</td>
<td>{{ row.product.reference || '—' }}</td> <td>{{ row.product.reference || '—' }}</td>
<td>{{ row.product.typeProduct?.name || '—' }}</td> <td>
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '' }}</span>
</td>
<td> <td>
<div <div
v-if="row.suppliers.visible.length" v-if="row.suppliers.visible.length"
@@ -382,10 +391,12 @@ const reload = async () => {
await loadProducts({ force: true }) await loadProducts({ force: true })
} }
const { confirm } = useConfirm()
const confirmDelete = async (product: Record<string, any>) => { const confirmDelete = async (product: Record<string, any>) => {
const confirmed = window.confirm( const confirmed = await confirm({
`Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`, message: `Voulez-vous vraiment supprimer le produit "${product.name}" ?\n\nCette action est irréversible.`,
) })
if (!confirmed) { if (!confirmed) {
return return
} }

View File

@@ -26,6 +26,10 @@
:initial-data="initialData" :initial-data="initialData"
:lock-category="true" :lock-category="true"
:saving="saving" :saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit" @submit="handleSubmit"
@cancel="handleCancel" @cancel="handleCancel"
/> />
@@ -38,16 +42,37 @@ import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports' import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue' import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes' import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const { loadProductTypes } = useProductTypes()
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null) const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,
} = useCategoryEditGuard({
endpoint: '/products',
filterKey: 'typeProduct',
labels: {
singular: 'produit',
plural: 'produits',
verifying: 'Vérification des produits liés en cours…',
},
})
const title = computed(() => const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit', initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
) )
@@ -84,8 +109,10 @@ const loadCategory = async () => {
code: response.code, code: response.code,
category: response.category, category: response.category,
notes: response.notes ?? response.description ?? '', notes: response.notes ?? response.description ?? '',
structure: response.structure ?? undefined, structure: (response.structure as ProductModelStructure | null) ?? undefined,
} }
await loadLinkedCount(id)
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))
await navigateBackToList() await navigateBackToList()
@@ -99,6 +126,9 @@ const handleCancel = () => {
} }
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => { const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (guardSubmitOrNotify()) {
return
}
const id = String(route.params.id) const id = String(route.params.id)
saving.value = true saving.value = true
try { try {
@@ -107,6 +137,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
description: payload?.notes ?? null, description: payload?.notes ?? null,
} }
await updateModelType(id, enrichedPayload) await updateModelType(id, enrichedPayload)
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.') showSuccess('Catégorie de produit mise à jour avec succès.')
await navigateBackToList() await navigateBackToList()
} catch (error) { } catch (error) {

View File

@@ -32,6 +32,7 @@ import { ref } from 'vue'
import { useHead, useRouter } from '#imports' import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue' import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes' import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
useHead(() => ({ useHead(() => ({
@@ -56,6 +57,7 @@ const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
description: payload.notes ?? null, description: payload.notes ?? null,
} }
await createModelType(enrichedPayload) await createModelType(enrichedPayload)
invalidateEntityTypeCache('PRODUCT')
showSuccess('Catégorie de produit créée avec succès.') showSuccess('Catégorie de produit créée avec succès.')
await router.push('/product-category') await router.push('/product-category')
} catch (error: any) { } catch (error: any) {

View File

@@ -301,6 +301,74 @@
</p> </p>
</div> </div>
<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="historyEntries.length" class="badge badge-outline">
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
</span>
</header>
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
Chargement de lhistorique…
</div>
<div v-else-if="historyError" class="alert alert-warning">
<span>{{ historyError }}</span>
</div>
<p v-else-if="historyEntries.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 historyEntries"
: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="historyDiffEntries(entry).length"
class="mt-2 space-y-1 text-xs"
>
<li
v-for="diffEntry in historyDiffEntries(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>
<div class="flex flex-col gap-3 md:flex-row md:justify-end"> <div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }"> <NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler Annuler
@@ -329,36 +397,50 @@ import { useCustomFields } from '~/composables/useCustomFields'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils' import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes' import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory' import type { ProductModelStructure } from '~/shared/types/inventory'
import { getFileIcon } from '~/utils/fileIcons' import { canPreviewDocument } from '~/utils/documentPreview'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview' import {
type CustomFieldInput,
interface CustomFieldInput { fieldKey,
id: string | null buildCustomFieldInputs,
name: string requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
type: string saveCustomFieldValues as _saveCustomFieldValues,
required: boolean } from '~/shared/utils/customFieldFormUtils'
options: string[] import {
value: string documentIcon,
customFieldId: string | null formatSize,
customFieldValueId: string | null shouldInlinePdf,
orderIndex: number documentPreviewSrc,
} documentThumbnailClass,
downloadDocument,
} from '~/shared/utils/documentDisplayUtils'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const { getProduct, updateProduct } = useProducts() const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields() const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { const {
loadDocumentsByProduct, loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments, uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument, deleteDocument: deleteProductDocument,
} = useDocuments() } = useDocuments()
const { ensureConstructeurs } = useConstructeurs() const { ensureConstructeurs } = useConstructeurs()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useProductHistory()
const product = ref<any | null>(null) const product = ref<any | null>(null)
const productType = ref<any | null>(null) const productType = ref<any | null>(null)
@@ -373,6 +455,19 @@ const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null) const previewDocument = ref<any | null>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
const historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
supplierPrice: 'Prix fournisseur',
typeProduct: 'Catégorie',
constructeurIds: 'Fournisseurs',
}
const historyDiffEntries = (entry: ProductHistoryEntry) =>
_historyDiffEntries(entry, historyFieldLabels)
const refreshCustomFieldInputs = ( const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null, structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null, valuesOverride?: any[] | null,
@@ -390,15 +485,7 @@ const editionForm = reactive({
}) })
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => { _requiredCustomFieldsFilled(customFieldInputs.value),
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim().length > 0
}),
) )
const canSubmit = computed(() => const canSubmit = computed(() =>
@@ -407,60 +494,6 @@ const canSubmit = computed(() =>
const structurePreview = computed(() => formatProductStructurePreview(structure.value)) const structurePreview = computed(() => formatProductStructurePreview(structure.value))
const documentIcon = (doc: any) =>
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
const formatSize = (size: number | null | undefined) => {
if (size === null || size === undefined) {
return '—'
}
if (size === 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024
const shouldInlinePdf = (document: any) => {
if (!document || !isPdfDocument(document) || !document.path) {
return false
}
if (typeof document.size === 'number' && document.size > PDF_PREVIEW_MAX_BYTES) {
return false
}
return true
}
const appendPdfViewerParams = (src: string) => {
if (!src || src.startsWith('data:')) {
return src || ''
}
if (src.includes('#')) {
return `${src}&toolbar=0&navpanes=0`
}
return `${src}#toolbar=0&navpanes=0`
}
const documentPreviewSrc = (document: any) => {
if (!document?.path) {
return ''
}
if (isPdfDocument(document)) {
return appendPdfViewerParams(document.path)
}
return document.path
}
const documentThumbnailClass = (document: any) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return 'h-24 w-20'
}
return 'h-16 w-16'
}
const openPreview = (doc: any) => { const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) { if (!doc || !canPreviewDocument(doc)) {
@@ -475,20 +508,6 @@ const closePreview = () => {
previewDocument.value = null previewDocument.value = null
} }
const downloadDocument = (doc: any) => {
if (!doc?.path) {
return
}
const target = String(doc.path)
if (target.startsWith('data:')) {
const link = document.createElement('a')
link.href = target
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(target, '_blank')
}
const loadProduct = async () => { const loadProduct = async () => {
const id = route.params.id const id = route.params.id
@@ -498,17 +517,20 @@ const loadProduct = async () => {
return return
} }
const result = await getProduct(id) const result = await getProduct(id)
if (result.success) { if (result.success && result.data) {
product.value = result.data product.value = result.data
productDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : [] productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
await loadProductType() await loadProductType()
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) { // Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
product.value.customFieldValues = customValues.data const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues.data) refreshCustomFieldInputs(undefined, customValues)
}
await hydrateForm() hydrateForm()
await refreshDocuments()
// History is non-blocking — template handles its own loading state
loadHistory(result.data.id).catch(() => {})
} else { } else {
product.value = null product.value = null
} }
@@ -567,9 +589,20 @@ const handleFilesAdded = async (files: File[]) => {
} }
const loadProductType = async () => { const loadProductType = async () => {
// Try using the expanded typeProduct from entity response first
const embedded = product.value?.typeProduct
if (embedded && typeof embedded === 'object' && embedded.id) {
const embeddedStructure = embedded.structure ?? embedded.productSkeleton ?? null
if (embeddedStructure) {
productType.value = embedded
structure.value = normalizeProductStructureForSave(embeddedStructure)
return
}
}
if (!product.value?.typeProductId) { if (!product.value?.typeProductId) {
productType.value = product.value?.typeProduct ?? null productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null) structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
return return
} }
try { try {
@@ -578,12 +611,12 @@ const loadProductType = async () => {
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null) structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement du type de produit:', error) console.error('Erreur lors du chargement du type de produit:', error)
productType.value = product.value?.typeProduct ?? null productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null) structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
} }
} }
const hydrateForm = async () => { const hydrateForm = () => {
if (!product.value) { if (!product.value) {
return return
} }
@@ -598,7 +631,8 @@ const hydrateForm = async () => {
: '' : ''
refreshCustomFieldInputs(structure.value, product.value.customFieldValues) refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
if (editionForm.constructeurIds.length) { if (editionForm.constructeurIds.length) {
await ensureConstructeurs(editionForm.constructeurIds) // Smart-cached + deduped — fire-and-forget, ConstructeurSelect handles its own loading
ensureConstructeurs(editionForm.constructeurIds).catch(() => {})
} }
} }
@@ -612,86 +646,6 @@ watch(
{ immediate: true }, { immediate: true },
) )
const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldValueId || field.customFieldId || `${field.name}-${index}`
const buildCustomFieldInputs = (
productStructure: ProductModelStructure | null,
values: any[] | null | undefined,
): CustomFieldInput[] => {
if (!productStructure || typeof productStructure !== 'object') {
return []
}
const definitions = Array.isArray(productStructure.customFields) ? productStructure.customFields : []
const valueList = Array.isArray(values) ? values : []
const byId = new Map<string, any>()
const byName = new Map<string, any>()
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return
}
const fieldId = entry.customField?.id || entry.customFieldId || null
if (fieldId) {
byId.set(fieldId, entry)
}
const fieldName = entry.customField?.name || entry.name || entry.key || null
if (fieldName) {
byName.set(fieldName, entry)
}
})
return definitions
.map((definition, index) => {
const definitionId = definition.customFieldId || definition.id || null
const matched = (definitionId ? byId.get(definitionId) : null) || byName.get(definition.name)
const type = typeof definition.type === 'string' ? definition.type : 'text'
const options = Array.isArray(definition.options) ? definition.options : []
const required = !!definition.required
const orderIndex = typeof definition.orderIndex === 'number' ? definition.orderIndex : index
if (!matched) {
return {
id: definition.id ?? null,
name: definition.name,
type,
required,
options,
value: '',
customFieldId: definition.customFieldId || definition.id || null,
customFieldValueId: null,
orderIndex,
}
}
const resolvedValue = matched.value ?? ''
return {
id: definition.id ?? null,
name: definition.name,
type,
required,
options,
value: formatDefaultValue(type, resolvedValue),
customFieldId: matched.customField?.id || definition.customFieldId || definition.id || null,
customFieldValueId: matched.id ?? null,
orderIndex,
}
})
.filter((field): field is CustomFieldInput => !!field?.name)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
const formatDefaultValue = (type: string, value: any): string => {
if (value === null || value === undefined) {
return ''
}
if (type === 'boolean') {
return String(value === true || String(value).toLowerCase() === 'true')
}
return String(value)
}
const submitEdition = async () => { const submitEdition = async () => {
if (!product.value) { if (!product.value) {
return return
@@ -719,7 +673,12 @@ const submitEdition = async () => {
const result = await updateProduct(product.value.id, payload) const result = await updateProduct(product.value.id, payload)
if (result.success && result.data?.id) { if (result.success && result.data?.id) {
product.value = result.data product.value = result.data
const failedFields = await saveCustomFieldValues(result.data.id) const failedFields = await _saveCustomFieldValues(
'product',
result.data.id,
[],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
if (failedFields.length) { if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`) toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return return
@@ -734,46 +693,6 @@ const submitEdition = async () => {
} }
} }
const saveCustomFieldValues = async (productId: string) => {
const failed: string[] = []
for (const field of customFieldInputs.value) {
const value = field.value ?? ''
if (field.customFieldValueId) {
const result = await updateCustomFieldValue(field.customFieldValueId, { value })
if (!result.success) {
failed.push(field.name)
}
continue
}
const metadata = field.customFieldId
? undefined
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
const result = await upsertCustomFieldValue(
field.customFieldId,
'product',
productId,
String(value ?? ''),
metadata,
)
if (!result.success) {
failed.push(field.name)
} else {
const createdValue = result.data
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
}
const resolvedId = createdValue?.customField?.id || field.customFieldId
if (resolvedId) {
field.customFieldId = resolvedId
}
}
}
return failed
}
onMounted(async () => { onMounted(async () => {
await loadProduct() await loadProduct()
}) })

View File

@@ -249,6 +249,10 @@ import { formatProductStructurePreview, normalizeProductStructureForSave } from
import { uniqueConstructeurIds } from '~/shared/constructeurUtils' import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import type { ProductModelStructure } from '~/shared/types/inventory' import type { ProductModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes' import type { ModelType } from '~/services/modelTypes'
import {
type CustomFieldInput,
normalizeCustomFieldInputs as normalizeCustomFieldInputsFromUtils,
} from '~/shared/utils/customFieldFormUtils'
interface ProductCatalogType extends ModelType { interface ProductCatalogType extends ModelType {
structure: ProductModelStructure | null structure: ProductModelStructure | null
@@ -276,17 +280,6 @@ const creationForm = reactive({
const selectedDocuments = ref<File[]>([]) const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false) const uploadingDocuments = ref(false)
interface CustomFieldInput {
id: string | null
name: string
type: string
required: boolean
options: string[]
value: string
customFieldId: string | null
orderIndex: number
}
const customFieldInputs = ref<CustomFieldInput[]>([]) const customFieldInputs = ref<CustomFieldInput[]>([])
const loadingTypes = computed(() => loadingProductTypes.value) const loadingTypes = computed(() => loadingProductTypes.value)
@@ -337,7 +330,7 @@ watch(selectedType, (type) => {
if (!creationForm.name) { if (!creationForm.name) {
creationForm.name = type.name creationForm.name = type.name
} }
customFieldInputs.value = normalizeCustomFieldInputs(normalizeProductStructureForSave(type.structure)) customFieldInputs.value = normalizeCustomFieldInputsFromUtils(normalizeProductStructureForSave(type.structure))
}) })
const requiredCustomFieldsFilled = computed(() => const requiredCustomFieldsFilled = computed(() =>
@@ -362,49 +355,6 @@ const canSubmit = computed(() => Boolean(
const fieldKey = (field: CustomFieldInput, index: number) => const fieldKey = (field: CustomFieldInput, index: number) =>
field.customFieldId || field.id || `${field.name}-${index}` field.customFieldId || field.id || `${field.name}-${index}`
const normalizeCustomFieldInputs = (structure: ProductModelStructure | null): CustomFieldInput[] => {
if (!structure || typeof structure !== 'object') {
return []
}
const fields = Array.isArray(structure.customFields) ? structure.customFields : []
return fields
.map((field, index) => normalizeCustomField(field, index))
.filter((field): field is CustomFieldInput => field !== null)
.sort((a, b) => a.orderIndex - b.orderIndex)
}
const normalizeCustomField = (rawField: any, fallbackIndex = 0): CustomFieldInput | null => {
if (!rawField || typeof rawField !== 'object') {
return null
}
const name = typeof rawField.name === 'string' ? rawField.name.trim() : ''
if (!name) {
return null
}
const type = typeof rawField.type === 'string' ? rawField.type : 'text'
const required = !!rawField.required
const options = Array.isArray(rawField.options)
? rawField.options.filter((option: unknown): option is string => typeof option === 'string')
: []
const defaultSource = rawField.defaultValue ?? rawField.value ?? rawField.default ?? null
const value = formatDefaultValue(type, defaultSource)
const id = typeof rawField.id === 'string' ? rawField.id : null
const customFieldId = typeof rawField.customFieldId === 'string' ? rawField.customFieldId : id
const orderIndex = typeof rawField.orderIndex === 'number' ? rawField.orderIndex : fallbackIndex
return { id, name, type, required, options, value, customFieldId, orderIndex }
}
const formatDefaultValue = (type: string, defaultValue: any): string => {
if (defaultValue === null || defaultValue === undefined) {
return ''
}
if (type === 'boolean') {
return String(defaultValue === true || String(defaultValue).toLowerCase() === 'true')
}
return String(defaultValue)
}
const clearForm = () => { const clearForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.reference = '' creationForm.reference = ''

View File

@@ -169,8 +169,10 @@ const create = async () => {
} }
} }
const { confirm } = useConfirm()
const remove = async (profileId) => { const remove = async (profileId) => {
if (!confirm('Supprimer ce profil ?')) { return } if (!await confirm({ message: 'Supprimer ce profil ?' })) { return }
deleting.value = profileId deleting.value = profileId
try { try {
await deleteProfile(profileId) await deleteProfile(profileId)

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