15 Commits

Author SHA1 Message Date
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
114 changed files with 11205 additions and 9136 deletions

View File

@@ -1,698 +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">
<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>
<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>
@@ -701,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 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";
import logoSrc from "~/assets/LOGO_CARRE_BLANC.png";
// É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>

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

@@ -115,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
} }
@@ -145,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
} }
@@ -246,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

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

@@ -118,7 +118,6 @@
type="text" type="text"
class="input input-bordered input-xs" class="input input-bordered input-xs"
placeholder="Nom du champ" placeholder="Nom du champ"
:disabled="isFieldLocked(field)"
> >
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)"> <select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<option value="text"> <option value="text">
@@ -511,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

@@ -114,7 +114,6 @@
type="text" type="text"
class="input input-bordered input-xs" class="input input-bordered input-xs"
placeholder="Nom du champ" placeholder="Nom du champ"
:disabled="isCustomFieldLocked(index)"
/> />
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)"> <select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
<option value="text">Texte</option> <option value="text">Texte</option>
@@ -541,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))
} }

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

@@ -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,368 @@
<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'],
children: [
{ to: '/sites', label: 'Sites' },
{ to: '/documents', label: 'Documents' },
{ to: '/constructeurs', label: 'Fournisseurs' },
],
},
]
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

@@ -97,6 +97,7 @@ 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 { useApi } from "~/composables/useApi";
import { extractCollection } from "~/shared/utils/apiHelpers";
import { import {
deleteModelType, deleteModelType,
listModelTypes, listModelTypes,
@@ -105,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.";
@@ -153,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;
}; };
@@ -208,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));
@@ -292,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) {
@@ -363,22 +368,6 @@ const relatedModalSubtitle = computed(() => {
return `${count} ${labels.plural} liés.`; return `${count} ${labels.plural} liés.`;
}); });
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?.items)) {
return payload.items;
}
return [];
};
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`; const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
const resolveRelatedConfig = (category: ModelCategory) => { const resolveRelatedConfig = (category: ModelCategory) => {
@@ -401,22 +390,26 @@ const resolveRelatedEditBasePath = (category: ModelCategory) => {
return "/product"; return "/product";
}; };
const mapRelatedEntry = (item: any): RelatedEntry | null => { const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
if (!item || typeof item !== "object" || typeof item.id !== "string") { if (!item || typeof item !== "object") {
return null;
}
const record = item as Record<string, unknown>;
if (typeof record.id !== "string") {
return null; return null;
} }
const name = const name =
typeof item.name === "string" && item.name.trim() typeof record.name === "string" && record.name.trim()
? item.name ? record.name
: "Sans nom"; : "Sans nom";
const reference = const reference =
typeof item.reference === "string" && item.reference.trim() typeof record.reference === "string" && record.reference.trim()
? item.reference ? record.reference
: typeof item.code === "string" && item.code.trim() : typeof record.code === "string" && record.code.trim()
? item.code ? record.code
: null; : null;
return { return {
id: item.id, id: record.id,
name, name,
reference, reference,
}; };

View File

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

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

@@ -57,7 +57,7 @@ export function useCategoryEditGuard (config: GuardConfig) {
: 0 : 0
linkedCount.value = extractTotal(result.data, fallbackLength) linkedCount.value = extractTotal(result.data, fallbackLength)
} catch (error) { } catch (_error) {
linkedCount.value = 0 linkedCount.value = 0
} finally { } finally {
linkedLoading.value = false linkedLoading.value = false
@@ -80,9 +80,9 @@ export function useCategoryEditGuard (config: GuardConfig) {
return '' return ''
} }
if (linkedCount.value === 1) { 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, mais pas modifier ou supprimer les existants.` 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, mais pas modifier ou supprimer les existants.` 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(() => { const submitBlockMessage = computed(() => {

View File

@@ -1,67 +1,12 @@
import { ref } from 'vue' /**
import { useApi } from '~/composables/useApi' * Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ComponentHistoryActor = { export type ComponentHistoryActor = EntityHistoryActor
id: string export type ComponentHistoryEntry = EntityHistoryEntry
label: string
export function useComponentHistory() {
return useEntityHistory('composant')
} }
export type ComponentHistoryEntry = {
id: string
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: ComponentHistoryActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
const extractItems = (payload: any): ComponentHistoryEntry[] => {
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 useComponentHistory () {
const { get } = useApi()
const history = ref<ComponentHistoryEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const loadHistory = async (componentId: string) => {
loading.value = true
error.value = null
try {
const result = await get(`/composants/${componentId}/history`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger lhistorique.'
history.value = []
return result
}
history.value = extractItems(result.data) as ComponentHistoryEntry[]
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

@@ -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,164 +1,29 @@
import { ref } from 'vue' /**
import { useToast } from './useToast' * Backward-compatible wrapper around useEntityTypes.
import { listModelTypes, createModelType, updateModelType, deleteModelType, type ModelType } from '~/services/modelTypes' * 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 { ComponentModelStructure } from '~/shared/types/inventory'
import type { Ref } from 'vue'
export interface ComponentType extends ModelType { export interface ComponentType extends EntityType {
structure: ComponentModelStructure | null structure: ComponentModelStructure | null
description?: string | null
} }
interface ComponentTypePayload {
name: string
code?: string
description?: string | null
notes?: string | null
structure?: ComponentModelStructure | null
}
interface ComponentTypeResult {
success: boolean
data?: ComponentType | ComponentType[]
error?: string
}
const componentTypes = ref<ComponentType[]>([])
const loadingComponentTypes = ref(false)
export function useComponentTypes() { export function useComponentTypes() {
const { showSuccess, showError } = useToast() const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
category: 'COMPONENT',
const generateCodeFromName = (name: string): string => { label: 'composant',
return (name || '') })
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || 'type'
}
const loadComponentTypes = async (): Promise<ComponentTypeResult> => {
loadingComponentTypes.value = true
try {
const data = await listModelTypes({
category: 'COMPONENT',
sort: 'name',
dir: 'asc',
limit: 200,
})
componentTypes.value = data.items.map((item) => ({
...item,
structure: item.structure as ComponentModelStructure | null,
description: item.description ?? item.notes ?? null,
}))
return { success: true, data: componentTypes.value }
} catch (error) {
const err = error as Error & { message?: string }
const message = err?.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: ComponentTypePayload): Promise<ComponentTypeResult> => {
loadingComponentTypes.value = true
try {
const data = await createModelType({
name: payload.name,
code: payload.code || generateCodeFromName(payload.name),
category: 'COMPONENT',
notes: payload.description ?? payload.notes ?? undefined,
description: payload.description ?? undefined,
structure: payload.structure ?? undefined,
})
const normalized: ComponentType = {
...data,
structure: data.structure as ComponentModelStructure | null,
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 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 composant: ${message}`)
return { success: false, error: message }
} finally {
loadingComponentTypes.value = false
}
}
const updateComponentType = async (id: string, payload: ComponentTypePayload): Promise<ComponentTypeResult> => {
loadingComponentTypes.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: ComponentType = {
...data,
structure: data.structure as ComponentModelStructure | null,
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 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 composant: ${message}`)
return { success: false, error: message }
} finally {
loadingComponentTypes.value = false
}
}
const deleteComponentType = async (id: string): Promise<ComponentTypeResult> => {
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 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 composant: ${message}`)
return { success: false, error: message }
} finally {
loadingComponentTypes.value = false
}
}
const getComponentTypes = () => componentTypes.value
const isComponentTypeLoading = () => loadingComponentTypes.value
return { return {
componentTypes, componentTypes: types as Ref<ComponentType[]>,
loadingComponentTypes, loadingComponentTypes: loading,
loadComponentTypes, loadComponentTypes: loadTypes,
createComponentType, createComponentType: createType,
updateComponentType, updateComponentType: updateType,
deleteComponentType, deleteComponentType: deleteType,
getComponentTypes, getComponentTypes: () => types.value as ComponentType[],
isComponentTypeLoading, isComponentTypeLoading: () => loading.value,
} }
} }

View File

@@ -4,6 +4,7 @@ import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Composant { export interface Composant {
id: string id: string
@@ -45,23 +46,6 @@ const composants = ref<Composant[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const extractCollection = (payload: unknown): Composant[] => {
if (Array.isArray(payload)) {
return payload as Composant[]
}
const p = payload as Record<string, unknown> | null
if (Array.isArray(p?.member)) {
return p.member as Composant[]
}
if (Array.isArray(p?.['hydra:member'])) {
return p['hydra:member'] as Composant[]
}
if (Array.isArray(p?.data)) {
return p.data as Composant[]
}
return []
}
const extractTotal = (payload: unknown, fallbackLength: number): number => { const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') { if (typeof p?.totalItems === 'number') {

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,6 +1,7 @@
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'
export interface Constructeur { export interface Constructeur {
id: string id: string
@@ -17,6 +18,7 @@ interface ConstructeurResult {
const constructeurs = ref<Constructeur[]>([]) const constructeurs = ref<Constructeur[]>([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false)
const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => { const uniqueConstructeurs = (items: Constructeur[] = []): Constructeur[] => {
const map = new Map<string, Constructeur>() const map = new Map<string, Constructeur>()
@@ -52,30 +54,16 @@ const upsertConstructeurs = (items: Constructeur[] = []) => {
const getIndexedConstructeur = (id: string): Constructeur | null => 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: unknown): Constructeur[] => {
if (Array.isArray(payload)) {
return payload as Constructeur[]
}
const p = payload as Record<string, unknown> | null
if (Array.isArray(p?.member)) {
return p.member as Constructeur[]
}
if (Array.isArray(p?.['hydra:member'])) {
return p['hydra:member'] as Constructeur[]
}
if (Array.isArray(p?.data)) {
return p.data as Constructeur[]
}
return []
}
const pendingFetches = new Map<string, Promise<Constructeur | null>>() const pendingFetches = new Map<string, Promise<Constructeur | null>>()
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 = ''): Promise<ConstructeurResult> => { 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)}` : ''
@@ -83,6 +71,7 @@ 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 as ConstructeurResult return result as ConstructeurResult
} catch (error) { } catch (error) {

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,6 +2,7 @@ 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'
export interface Document { export interface Document {
id: string id: string
@@ -34,23 +35,6 @@ export interface DocumentResult {
const documents = ref<Document[]>([]) const documents = ref<Document[]>([])
const loading = ref(false) const loading = ref(false)
const extractCollection = (payload: unknown): Document[] => {
if (Array.isArray(payload)) {
return payload
}
const p = payload as Record<string, unknown> | null
if (Array.isArray(p?.member)) {
return p.member as Document[]
}
if (Array.isArray(p?.['hydra:member'])) {
return p['hydra:member'] as Document[]
}
if (Array.isArray(p?.data)) {
return p.data as Document[]
}
return []
}
const fileToBase64 = (file: File): Promise<string> => const fileToBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()

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

@@ -6,6 +6,7 @@
*/ */
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed } from 'vue'
import { extractCollection } from '~/shared/utils/apiHelpers'
type AnyRecord = Record<string, unknown> type AnyRecord = Record<string, unknown>
@@ -17,14 +18,6 @@ export interface MachineCreateSelectionsDeps {
toast: { showError: (msg: string) => void } toast: { showError: (msg: string) => void }
} }
const extractCollection = (payload: unknown): unknown[] => {
if (Array.isArray(payload)) return payload
if (Array.isArray((payload as AnyRecord)?.member)) return (payload as AnyRecord).member as unknown[]
if (Array.isArray((payload as AnyRecord)?.['hydra:member'])) return (payload as AnyRecord)['hydra:member'] as unknown[]
if (Array.isArray((payload as AnyRecord)?.data)) return (payload as AnyRecord).data as unknown[]
return []
}
export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) { export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
const { findComponentById, findPieceById, pieces, get, toast } = deps const { findComponentById, findPieceById, pieces, get, toast } = deps
@@ -317,11 +310,12 @@ export function useMachineCreateSelections(deps: MachineCreateSelectionsDeps) {
const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number const min = (requirement.minCount ?? (requirement.required ? 1 : 0)) as number
const initialCount = Math.max(min, requirement.required ? 1 : 0) const initialCount = Math.max(min, requirement.required ? 1 : 0)
if (initialCount > 0) { if (initialCount > 0) {
pieceRequirementSelections[requirement.id as string] = Array.from( const entries = Array.from(
{ length: initialCount }, { length: initialCount },
() => createPieceSelectionEntry(requirement), () => createPieceSelectionEntry(requirement),
) )
pieceRequirementSelections[requirement.id as string].forEach((_: unknown, index: number) => { pieceRequirementSelections[requirement.id as string] = entries
entries.forEach((_: unknown, index: number) => {
fetchPieceOptions(requirement, index).catch(() => {}) fetchPieceOptions(requirement, index).catch(() => {})
}) })
} else { } else {

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
* Extracted from pages/machine/[id].vue to keep the page orchestrator lean. * Extracted from pages/machine/[id].vue to keep the page orchestrator lean.
*/ */
import { resolveIdentifier, resolveProductReference, getProductDisplay } from '~/shared/utils/productDisplayUtils' import { resolveIdentifier, getProductDisplay } from '~/shared/utils/productDisplayUtils'
import { resolveConstructeurs, uniqueConstructeurIds, type ConstructeurSummary } from '~/shared/constructeurUtils' import { resolveConstructeurs, uniqueConstructeurIds, type ConstructeurSummary } from '~/shared/constructeurUtils'
type AnyRecord = Record<string, unknown> type AnyRecord = Record<string, unknown>

View File

@@ -6,7 +6,6 @@
import { ref, reactive, nextTick } from 'vue' import { ref, reactive, nextTick } from 'vue'
import { buildMachinePrintContext, buildMachinePrintHtml } from '~/utils/printTemplates/machineReport' import { buildMachinePrintContext, buildMachinePrintHtml } from '~/utils/printTemplates/machineReport'
import { resolveIdentifier } from '~/shared/utils/productDisplayUtils'
type AnyRecord = Record<string, unknown> type AnyRecord = Record<string, unknown>

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

@@ -1,67 +1,12 @@
import { ref } from 'vue' /**
import { useApi } from '~/composables/useApi' * Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type PieceHistoryActor = { export type PieceHistoryActor = EntityHistoryActor
id: string export type PieceHistoryEntry = EntityHistoryEntry
label: string
export function usePieceHistory() {
return useEntityHistory('piece')
} }
export type PieceHistoryEntry = {
id: string
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: PieceHistoryActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
const extractItems = (payload: any): PieceHistoryEntry[] => {
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 usePieceHistory () {
const { get } = useApi()
const history = ref<PieceHistoryEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const loadHistory = async (pieceId: string) => {
loading.value = true
error.value = null
try {
const result = await get(`/pieces/${pieceId}/history`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger lhistorique.'
history.value = []
return result
}
history.value = extractItems(result.data) as PieceHistoryEntry[]
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

@@ -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,164 +1,29 @@
import { ref } from 'vue' /**
import { useToast } from './useToast' * Backward-compatible wrapper around useEntityTypes.
import { listModelTypes, createModelType, updateModelType, deleteModelType, type ModelType } from '~/services/modelTypes' * 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 { PieceModelStructure } from '~/shared/types/inventory'
import type { Ref } from 'vue'
export interface PieceType extends ModelType { export interface PieceType extends EntityType {
structure: PieceModelStructure | null structure: PieceModelStructure | null
description?: string | null
} }
interface PieceTypePayload {
name: string
code?: string
description?: string | null
notes?: string | null
structure?: PieceModelStructure | null
}
interface PieceTypeResult {
success: boolean
data?: PieceType | PieceType[]
error?: string
}
const pieceTypes = ref<PieceType[]>([])
const loadingPieceTypes = ref(false)
export function usePieceTypes() { export function usePieceTypes() {
const { showSuccess, showError } = useToast() const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
category: 'PIECE',
const generateCodeFromName = (name: string): string => { label: 'pièce',
return (name || '') })
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || 'type'
}
const loadPieceTypes = async (): Promise<PieceTypeResult> => {
loadingPieceTypes.value = true
try {
const data = await listModelTypes({
category: 'PIECE',
sort: 'name',
dir: 'asc',
limit: 200,
})
pieceTypes.value = data.items.map((item) => ({
...item,
structure: item.structure as PieceModelStructure | null,
description: item.description ?? item.notes ?? null,
}))
return { success: true, data: pieceTypes.value }
} catch (error) {
const err = error as Error & { message?: string }
const message = err?.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: PieceTypePayload): Promise<PieceTypeResult> => {
loadingPieceTypes.value = true
try {
const data = await createModelType({
name: payload.name,
code: payload.code || generateCodeFromName(payload.name),
category: 'PIECE',
notes: payload.description ?? payload.notes ?? undefined,
description: payload.description ?? undefined,
structure: payload.structure ?? undefined,
})
const normalized: PieceType = {
...data,
structure: data.structure as PieceModelStructure | null,
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 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 pièce: ${message}`)
return { success: false, error: message }
} finally {
loadingPieceTypes.value = false
}
}
const updatePieceType = async (id: string, payload: PieceTypePayload): Promise<PieceTypeResult> => {
loadingPieceTypes.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: PieceType = {
...data,
structure: data.structure as PieceModelStructure | null,
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 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 pièce: ${message}`)
return { success: false, error: message }
} finally {
loadingPieceTypes.value = false
}
}
const deletePieceType = async (id: string): Promise<PieceTypeResult> => {
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 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 pièce: ${message}`)
return { success: false, error: message }
} finally {
loadingPieceTypes.value = false
}
}
const getPieceTypes = () => pieceTypes.value
const isPieceTypeLoading = () => loadingPieceTypes.value
return { return {
pieceTypes, pieceTypes: types as Ref<PieceType[]>,
loadingPieceTypes, loadingPieceTypes: loading,
loadPieceTypes, loadPieceTypes: loadTypes,
createPieceType, createPieceType: createType,
updatePieceType, updatePieceType: updateType,
deletePieceType, deletePieceType: deleteType,
getPieceTypes, getPieceTypes: () => types.value as PieceType[],
isPieceTypeLoading, isPieceTypeLoading: () => loading.value,
} }
} }

View File

@@ -4,6 +4,7 @@ import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Piece { export interface Piece {
id: string id: string
@@ -46,23 +47,6 @@ const pieces = ref<Piece[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const extractCollection = (payload: unknown): Piece[] => {
if (Array.isArray(payload)) {
return payload as Piece[]
}
const p = payload as Record<string, unknown> | null
if (Array.isArray(p?.member)) {
return p.member as Piece[]
}
if (Array.isArray(p?.['hydra:member'])) {
return p['hydra:member'] as Piece[]
}
if (Array.isArray(p?.data)) {
return p.data as Piece[]
}
return []
}
const extractTotal = (payload: unknown, fallbackLength: number): number => { const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') { if (typeof p?.totalItems === 'number') {

View File

@@ -1,67 +1,12 @@
import { ref } from 'vue' /**
import { useApi } from '~/composables/useApi' * Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ProductHistoryActor = { export type ProductHistoryActor = EntityHistoryActor
id: string export type ProductHistoryEntry = EntityHistoryEntry
label: string
export function useProductHistory() {
return useEntityHistory('product')
} }
export type ProductHistoryEntry = {
id: string
action: 'create' | 'update' | 'delete' | string
createdAt: string
actor: ProductHistoryActor | null
diff: Record<string, { from: unknown; to: unknown }> | null
snapshot: Record<string, unknown> | null
}
const extractItems = (payload: any): ProductHistoryEntry[] => {
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 useProductHistory () {
const { get } = useApi()
const history = ref<ProductHistoryEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const loadHistory = async (productId: string) => {
loading.value = true
error.value = null
try {
const result = await get(`/products/${productId}/history`)
if (!result.success) {
error.value = result.error ?? 'Impossible de charger lhistorique.'
history.value = []
return result
}
history.value = extractItems(result.data) as ProductHistoryEntry[]
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

@@ -1,159 +1,27 @@
import { ref } from 'vue' /**
import { useToast } from './useToast' * Backward-compatible wrapper around useEntityTypes.
import { listModelTypes, createModelType, updateModelType, deleteModelType, type ModelType } from '~/services/modelTypes' * 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 { ProductModelStructure } from '~/shared/types/inventory'
import type { Ref } from 'vue'
export interface ProductType extends ModelType { export interface ProductType extends EntityType {
structure: ProductModelStructure | null structure: ProductModelStructure | null
description?: string | null
} }
interface ProductTypePayload {
name: string
code?: string
description?: string | null
notes?: string | null
structure?: ProductModelStructure | null
}
interface ProductTypeResult {
success: boolean
data?: ProductType | ProductType[]
error?: string
}
const productTypes = ref<ProductType[]>([])
const loadingProductTypes = ref(false)
export function useProductTypes() { export function useProductTypes() {
const { showSuccess, showError } = useToast() const { types, loading, loadTypes, createType, updateType, deleteType } = useEntityTypes({
category: 'PRODUCT',
const generateCodeFromName = (name: string): string => { label: 'produit',
return (name || '') })
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-') || 'type'
}
const loadProductTypes = async (): Promise<ProductTypeResult> => {
loadingProductTypes.value = true
try {
const data = await listModelTypes({
category: 'PRODUCT',
sort: 'name',
dir: 'asc',
limit: 200,
})
productTypes.value = data.items.map((item) => ({
...item,
structure: item.structure as ProductModelStructure | null,
description: item.description ?? item.notes ?? null,
}))
return { success: true, data: productTypes.value }
} catch (error) {
const err = error as Error & { message?: string }
const message = err?.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: ProductTypePayload): Promise<ProductTypeResult> => {
loadingProductTypes.value = true
try {
const data = await createModelType({
name: payload.name,
code: payload.code || generateCodeFromName(payload.name),
category: 'PRODUCT',
notes: payload.description ?? payload.notes ?? undefined,
description: payload.description ?? undefined,
structure: payload.structure ?? undefined,
})
const normalized: ProductType = {
...data,
structure: data.structure as ProductModelStructure | null,
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 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 produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
const updateProductType = async (id: string, payload: ProductTypePayload): Promise<ProductTypeResult> => {
loadingProductTypes.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: ProductType = {
...data,
structure: data.structure as ProductModelStructure | null,
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 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 produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
const deleteProductType = async (id: string): Promise<ProductTypeResult> => {
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 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 produit: ${message}`)
return { success: false, error: message }
} finally {
loadingProductTypes.value = false
}
}
return { return {
productTypes, productTypes: types as Ref<ProductType[]>,
loadingProductTypes, loadingProductTypes: loading,
loadProductTypes, loadProductTypes: loadTypes,
createProductType, createProductType: createType,
updateProductType, updateProductType: updateType,
deleteProductType, deleteProductType: deleteType,
} }
} }

View File

@@ -4,6 +4,7 @@ import { useApi } from './useApi'
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils' import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs' import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations' import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Product { export interface Product {
id: string id: string
@@ -62,23 +63,6 @@ const replaceInCache = (item: Product): boolean => {
return false return false
} }
const extractCollection = (payload: unknown): Product[] => {
if (Array.isArray(payload)) {
return payload as Product[]
}
const p = payload as Record<string, unknown> | null
if (Array.isArray(p?.member)) {
return p.member as Product[]
}
if (Array.isArray(p?.['hydra:member'])) {
return p['hydra:member'] as Product[]
}
if (Array.isArray(p?.data)) {
return p.data as Product[]
}
return []
}
const extractTotal = (payload: unknown, fallbackLength: number): number => { const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') { if (typeof p?.totalItems === 'number') {
@@ -131,8 +115,16 @@ export function useProducts() {
itemsPerPage = 30, itemsPerPage = 30,
orderBy = 'name', orderBy = 'name',
orderDir = 'asc', orderDir = 'asc',
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,

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,
} }
} }
} }
@@ -251,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,6 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useApi } from './useApi' import { useApi } from './useApi'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Site { export interface Site {
id: string id: string
@@ -23,38 +24,21 @@ interface SiteResult {
const sites = ref<Site[]>([]) const sites = ref<Site[]>([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false)
const extractCollection = (payload: unknown): Site[] => {
if (Array.isArray(payload)) {
return payload as Site[]
}
const p = payload as Record<string, unknown> | null
if (Array.isArray(p?.member)) {
return p.member as Site[]
}
if (Array.isArray(p?.['hydra:member'])) {
return p['hydra:member'] as Site[]
}
if (Array.isArray(p?.data)) {
return p.data as Site[]
}
return []
}
export function useSites() { export function useSites() {
const { showSuccess, showInfo } = useToast() const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi() const { get, post, patch, delete: del } = useApi()
const loadSites = async (): Promise<void> => { const loadSites = async (options: { force?: boolean } = {}): Promise<void> => {
if (!options.force && loaded.value) return
loading.value = true loading.value = true
try { try {
const result = await get('/sites') const result = await get('/sites')
console.log('sites api result', result)
if (result.success) { if (result.success) {
const collection = extractCollection(result.data) const collection = extractCollection(result.data)
sites.value = collection sites.value = collection
showInfo(`Chargement de ${collection.length} site(s) réussi`) loaded.value = true
} }
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement des sites:', error) console.error('Erreur lors du chargement des sites:', error)

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

View File

@@ -327,7 +327,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

@@ -42,12 +42,15 @@ 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 { 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)
@@ -108,7 +111,7 @@ 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) await loadLinkedCount(id)
@@ -136,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

@@ -558,7 +558,6 @@ import {
import { import {
historyActionLabel, historyActionLabel,
formatHistoryDate, formatHistoryDate,
formatHistoryValue,
historyDiffEntries as _historyDiffEntries, historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils' } from '~/shared/utils/historyDisplayUtils'
@@ -709,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
@@ -851,8 +850,8 @@ 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( await _saveCustomFieldValues(
'composant', 'composant',
updatedComponent.id, updatedComponent.id,
@@ -925,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
@@ -968,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

View File

@@ -813,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
@@ -1144,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

@@ -42,12 +42,15 @@ 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 { 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)
@@ -106,7 +109,7 @@ 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) await loadLinkedCount(id)
@@ -134,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

@@ -189,7 +189,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 +442,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

@@ -503,7 +503,6 @@ import {
import { import {
historyActionLabel, historyActionLabel,
formatHistoryDate, formatHistoryDate,
formatHistoryValue,
historyDiffEntries as _historyDiffEntries, historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils' } from '~/shared/utils/historyDisplayUtils'
@@ -626,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
@@ -776,9 +775,9 @@ const loadPieceTypeDetails = async (currentPiece: any) => {
const type = await getModelType(typeId) const type = await getModelType(typeId)
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 (_error) {
pieceTypeDetails.value = null pieceTypeDetails.value = null
} }
} }
@@ -898,8 +897,8 @@ 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( await _saveCustomFieldValues(
'piece', 'piece',
updatedPiece.id, updatedPiece.id,

View File

@@ -543,22 +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) {
const createdPiece = result.data as Record<string, any>
await _saveCustomFieldValues( await _saveCustomFieldValues(
'piece', 'piece',
result.data.id, createdPiece.id,
[ [
result.data?.typePiece?.pieceCustomFields, createdPiece?.typePiece?.pieceCustomFields,
result.data?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields, createdPiece?.typeMachinePieceRequirement?.typePiece?.pieceCustomFields,
], ],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast }, { customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
) )
if (selectedDocuments.value.length && result.data?.id) { 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 },
) )

View File

@@ -382,10 +382,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

@@ -42,12 +42,15 @@ 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 { 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)
@@ -106,7 +109,7 @@ 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) await loadLinkedCount(id)
@@ -134,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

@@ -421,7 +421,6 @@ import {
import { import {
historyActionLabel, historyActionLabel,
formatHistoryDate, formatHistoryDate,
formatHistoryValue,
historyDiffEntries as _historyDiffEntries, historyDiffEntries as _historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils' } from '~/shared/utils/historyDisplayUtils'
@@ -518,9 +517,9 @@ 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) const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
if (customValues.success && Array.isArray(customValues.data)) { if (customValues.success && Array.isArray(customValues.data)) {

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)

View File

@@ -185,25 +185,16 @@ const toDisplayCount = (value, fallback) => {
onMounted(async () => { onMounted(async () => {
try { try {
const typeId = route.params.id const typeId = route.params.id
console.log('=== TYPE DETAIL PAGE LOADING ===')
console.log('Loading type with ID:', typeId)
console.log('Full route params:', route.params)
if (!typeId) { if (!typeId) {
console.error('No type ID provided in route')
showError('Aucun identifiant de type fourni') showError('Aucun identifiant de type fourni')
loading.value = false loading.value = false
return return
} }
const result = await getMachineTypeById(typeId) const result = await getMachineTypeById(typeId)
console.log('API Result:', result)
if (result.success) { if (result.success) {
type.value = result.data type.value = result.data
console.log('Type loaded successfully:', type.value)
} else { } else {
console.error('Failed to load type:', result.error)
showError('Type non trouvé') showError('Type non trouvé')
} }
} catch (error) { } catch (error) {
@@ -211,7 +202,6 @@ onMounted(async () => {
showError('Erreur lors du chargement') showError('Erreur lors du chargement')
} finally { } finally {
loading.value = false loading.value = false
console.log('Loading finished, loading.value:', loading.value)
} }
}) })
</script> </script>

View File

@@ -206,15 +206,9 @@ const saveChanges = async () => {
onMounted(async () => { onMounted(async () => {
try { try {
const typeId = route.params.id const typeId = route.params.id
console.log('=== EDIT TYPE PAGE LOADING ===')
console.log('Loading type with ID:', typeId)
const result = await getMachineTypeById(typeId, true) const result = await getMachineTypeById(typeId, true)
console.log('API Result:', result)
if (result.success) { if (result.success) {
type.value = result.data type.value = result.data
console.log('Type loaded successfully:', type.value)
// Initialiser les données éditées // Initialiser les données éditées
editedType.value = { editedType.value = {
@@ -236,7 +230,6 @@ onMounted(async () => {
showError('Erreur lors du chargement') showError('Erreur lors du chargement')
} finally { } finally {
loading.value = false loading.value = false
console.log('Loading finished, loading.value:', loading.value)
} }
}) })
</script> </script>

View File

@@ -1,5 +1,4 @@
import { useRequestFetch } from '#imports'; import { useRequestFetch } from '#imports';
import type { FetchOptions } from 'ofetch';
import type { import type {
ComponentModelStructure, ComponentModelStructure,
PieceModelStructure, PieceModelStructure,

View File

@@ -0,0 +1,794 @@
import {
createEmptyComponentModelStructure,
type ComponentModelCustomFieldType,
type ComponentModelCustomField,
type ComponentModelPiece,
type ComponentModelProduct,
type ComponentModelStructure,
type ComponentModelStructureNode,
} from '../types/inventory'
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
export interface ModelStructurePreview {
customFields: number
pieces: number
products: number
subcomponents: number
}
export const defaultStructure = (): ComponentModelStructure => ({
...createEmptyComponentModelStructure(),
})
const ensureStructureShape = (input: any): ComponentModelStructure => {
const base = createEmptyComponentModelStructure()
if (!isPlainObject(input)) {
return base
}
const clone: ComponentModelStructure = {
...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
pieces: Array.isArray((input as any).pieces) ? (input as any).pieces : [],
products: Array.isArray((input as any).products) ? (input as any).products : [],
subcomponents: Array.isArray((input as any).subcomponents)
? (input as any).subcomponents
: Array.isArray((input as any).subComponents)
? (input as any).subComponents
: [],
typeComposantId: typeof (input as any).typeComposantId === 'string' ? (input as any).typeComposantId : undefined,
typeComposantLabel: typeof (input as any).typeComposantLabel === 'string'
? (input as any).typeComposantLabel
: undefined,
modelId: typeof (input as any).modelId === 'string' ? (input as any).modelId : undefined,
familyCode: typeof (input as any).familyCode === 'string' ? (input as any).familyCode : undefined,
alias: typeof (input as any).alias === 'string' ? (input as any).alias : undefined,
}
return clone
}
export const cloneStructure = (input: any): ComponentModelStructure => {
try {
const cloned = JSON.parse(JSON.stringify(input ?? defaultStructure()))
return ensureStructureShape(cloned)
} catch (_error) {
return defaultStructure()
}
}
export const toStringArray = (input: unknown): string[] | undefined => {
if (!Array.isArray(input)) {
return undefined
}
const parsed = input
.map((value) => {
if (typeof value === 'string') {
return value.trim()
}
if (value === null || value === undefined) {
return ''
}
return String(value).trim()
})
.filter((value) => value.length > 0)
return parsed.length ? parsed : undefined
}
export const extractFieldValueObject = (field: any): Record<string, any> => {
if (isPlainObject(field?.value)) {
return field.value as Record<string, any>
}
return {}
}
export const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[] => {
if (!Array.isArray(fields)) {
return []
}
return fields
.map((field, index) => {
const rawName =
typeof field?.name === 'string'
? field.name
: typeof field?.key === 'string'
? field.key
: ''
const name = rawName.trim()
if (!name) {
return null
}
const valueObject = extractFieldValueObject(field)
const candidateType =
typeof field?.type === 'string' && field.type
? field.type
: typeof valueObject?.type === 'string'
? valueObject.type
: ''
const allowedTypes: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const type = allowedTypes.includes(candidateType as ComponentModelCustomFieldType)
? (candidateType as ComponentModelCustomFieldType)
: 'text'
const required =
typeof valueObject?.required === 'boolean' ? valueObject.required : !!field?.required
let options: string[] | undefined
if (type === 'select') {
options =
toStringArray(valueObject?.options) ||
toStringArray((valueObject as any)?.choices) ||
toStringArray(field?.options)
if (!options && typeof field?.optionsText === 'string') {
const parsedFromText = field.optionsText
.split(/\r?\n/)
.map((option: string) => option.trim())
.filter((option: string) => option.length > 0)
options = parsedFromText.length ? parsedFromText : undefined
}
}
const result: ComponentModelCustomField = { name, type, required }
if (options) {
result.options = options
}
const defaultCandidate =
field?.defaultValue ?? valueObject?.defaultValue ?? field?.value ?? field?.default ?? null
const resolvedDefault = (() => {
if (defaultCandidate === undefined || defaultCandidate === null) {
return undefined
}
if (typeof defaultCandidate === 'object') {
if (defaultCandidate === null) {
return undefined
}
if ('defaultValue' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).defaultValue
}
if ('value' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).value
}
return undefined
}
return defaultCandidate
})()
if (resolvedDefault !== undefined && resolvedDefault !== null && resolvedDefault !== '') {
result.defaultValue = String(resolvedDefault)
}
const id = typeof field?.id === 'string' ? field.id : undefined
if (id) {
result.id = id
}
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
if (customFieldId) {
result.customFieldId = customFieldId
}
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex
return result
})
.filter((field): field is ComponentModelCustomField => !!field)
}
export const sanitizePieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces
.map((piece) => {
const rawTypePieceId = typeof piece?.typePieceId === 'string'
? piece.typePieceId.trim()
: typeof piece?.typePiece?.id === 'string'
? piece.typePiece.id.trim()
: ''
const typePieceId = rawTypePieceId.length > 0 ? rawTypePieceId : undefined
const rawTypePieceLabel = typeof piece?.typePieceLabel === 'string'
? piece.typePieceLabel.trim()
: typeof piece?.typePiece?.name === 'string'
? piece.typePiece.name.trim()
: ''
const typePieceLabel = rawTypePieceLabel.length > 0 ? rawTypePieceLabel : undefined
const reference = typeof piece?.reference === 'string' && piece.reference.trim().length > 0
? piece.reference.trim()
: undefined
const rawFamilyCode = typeof piece?.familyCode === 'string'
? piece.familyCode.trim()
: typeof piece?.typePiece?.code === 'string'
? piece.typePiece.code.trim()
: ''
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
const rawRole = typeof piece?.role === 'string' ? piece.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
return null
}
const result: ComponentModelPiece = {}
if (role) {
result.role = role
}
if (familyCode) {
result.familyCode = familyCode
}
if (reference !== undefined) {
result.reference = reference
}
if (typePieceId) {
result.typePieceId = typePieceId
}
if (typePieceLabel) {
result.typePieceLabel = typePieceLabel
}
return result
})
.filter((piece): piece is ComponentModelPiece => !!piece)
}
export const sanitizeProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products
.map((product) => {
const rawTypeProductId = typeof product?.typeProductId === 'string'
? product.typeProductId.trim()
: typeof product?.typeProduct?.id === 'string'
? product.typeProduct.id.trim()
: ''
const typeProductId = rawTypeProductId.length > 0 ? rawTypeProductId : undefined
const rawTypeProductLabel = typeof product?.typeProductLabel === 'string'
? product.typeProductLabel.trim()
: typeof product?.typeProduct?.name === 'string'
? product.typeProduct.name.trim()
: ''
const typeProductLabel = rawTypeProductLabel.length > 0 ? rawTypeProductLabel : undefined
const reference = typeof product?.reference === 'string' && product.reference.trim().length > 0
? product.reference.trim()
: undefined
const rawFamilyCode = typeof product?.familyCode === 'string'
? product.familyCode.trim()
: typeof product?.typeProduct?.code === 'string'
? product.typeProduct.code.trim()
: ''
const familyCode = rawFamilyCode.length > 0 ? rawFamilyCode : undefined
const rawRole = typeof product?.role === 'string' ? product.role.trim() : ''
const role = rawRole.length > 0 ? rawRole : undefined
if (!typeProductId && !typeProductLabel && !reference && !familyCode) {
return null
}
const result: ComponentModelProduct = {}
if (role) {
result.role = role
}
if (familyCode) {
result.familyCode = familyCode
}
if (reference !== undefined) {
result.reference = reference
}
if (typeProductId) {
result.typeProductId = typeProductId
}
if (typeProductLabel) {
result.typeProductLabel = typeProductLabel
}
return result
})
.filter((product): product is ComponentModelProduct => !!product)
}
const sanitizeSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components
.map((component) => {
const rawTypeComposantId = typeof component?.typeComposantId === 'string'
? component.typeComposantId.trim()
: typeof component?.typeComposant?.id === 'string'
? component.typeComposant.id.trim()
: ''
const typeComposantId = rawTypeComposantId.length > 0 ? rawTypeComposantId : undefined
const modelId = typeof component?.modelId === 'string' && component.modelId.trim().length > 0
? component.modelId.trim()
: undefined
const familyCode = typeof component?.familyCode === 'string' && component.familyCode.trim().length > 0
? component.familyCode.trim()
: undefined
const alias = typeof component?.alias === 'string' && component.alias.trim().length > 0
? component.alias.trim()
: undefined
if (!typeComposantId && !modelId && !familyCode) {
return null
}
const result: ComponentModelStructureNode = {
subcomponents: sanitizeSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}
if (typeComposantId) {
result.typeComposantId = typeComposantId
}
const typeComposantLabel = typeof component?.typeComposantLabel === 'string'
? component.typeComposantLabel.trim()
: typeof component?.typeComposant?.name === 'string'
? component.typeComposant.name.trim()
: ''
if (typeComposantLabel) {
result.typeComposantLabel = typeComposantLabel
}
if (modelId) {
result.modelId = modelId
}
if (familyCode) {
result.familyCode = familyCode
}
if (alias) {
result.alias = alias
}
return result
})
.filter((component): component is ComponentModelStructureNode => !!component)
}
const hydrateCustomFields = (fields: any[]): any[] => {
if (!Array.isArray(fields)) {
return []
}
return fields.map((field, index) => {
const valueObject = extractFieldValueObject(field)
const name = typeof field?.name === 'string'
? field.name
: typeof field?.key === 'string'
? field.key
: ''
const candidateType =
typeof field?.type === 'string' && field.type
? field.type
: typeof valueObject?.type === 'string'
? valueObject.type
: ''
const allowedTypes: ComponentModelCustomFieldType[] = ['text', 'number', 'select', 'boolean', 'date']
const type = allowedTypes.includes(candidateType as ComponentModelCustomFieldType)
? (candidateType as ComponentModelCustomFieldType)
: 'text'
const required =
typeof field?.required === 'boolean'
? field.required
: typeof valueObject?.required === 'boolean'
? valueObject.required
: false
const options =
toStringArray(field?.options) ||
toStringArray(valueObject?.options) ||
toStringArray((valueObject as any)?.choices) ||
[]
const optionsText = typeof field?.optionsText === 'string'
? field.optionsText
: options.length
? options.join('\n')
: ''
const defaultCandidate =
field?.defaultValue ?? valueObject?.defaultValue ?? field?.value ?? field?.default ?? null
const resolvedDefault = (() => {
if (defaultCandidate === undefined || defaultCandidate === null) {
return undefined
}
if (typeof defaultCandidate === 'object') {
if (defaultCandidate === null) {
return undefined
}
if ('defaultValue' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).defaultValue
}
if ('value' in (defaultCandidate as Record<string, any>)) {
return (defaultCandidate as Record<string, any>).value
}
return undefined
}
return defaultCandidate
})()
const defaultValue =
resolvedDefault !== undefined && resolvedDefault !== null && resolvedDefault !== ''
? String(resolvedDefault)
: ''
const id = typeof field?.id === 'string' ? field.id : undefined
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
return {
name,
type,
required,
options,
optionsText,
defaultValue,
id,
customFieldId,
orderIndex,
}
})
}
const hydratePieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces.map((piece) => ({
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
reference: piece?.reference ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '',
}))
}
export const hydrateProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
reference: product?.reference ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
const hydrateSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components.map((component) => ({
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
subcomponents: hydrateSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}))
}
export const normalizeStructureForEditor = (input: any): ComponentModelStructure => {
const source = cloneStructure(input)
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
const customFields = sanitizedCustomFields.map((field) => {
const options = Array.isArray(field.options) ? [...field.options] : []
const optionsText = options.length ? options.join('\n') : ''
const defaultValue =
field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== ''
? String(field.defaultValue)
: null
const copy: ComponentModelCustomField = {
name: field.name,
type: field.type,
required: field.required,
options,
defaultValue,
optionsText,
id: field.id,
customFieldId: field.customFieldId,
}
return copy
})
const result: ComponentModelStructure = {
customFields: customFields as ComponentModelCustomField[],
pieces: sanitizePieces(source.pieces),
products: sanitizeProducts(source.products),
subcomponents: hydrateSubcomponents(source.subcomponents),
}
if (typeof source.typeComposantId === 'string' && source.typeComposantId.length > 0) {
result.typeComposantId = source.typeComposantId
}
if (typeof source.typeComposantLabel === 'string' && source.typeComposantLabel.length > 0) {
result.typeComposantLabel = source.typeComposantLabel
}
if (typeof source.modelId === 'string' && source.modelId.length > 0) {
result.modelId = source.modelId
}
if (typeof source.familyCode === 'string' && source.familyCode.length > 0) {
result.familyCode = source.familyCode
}
if (typeof source.alias === 'string' && source.alias.length > 0) {
result.alias = source.alias
}
return result
}
export const normalizeStructureForSave = (input: any): any => {
const source = cloneStructure(input)
const sanitizedCustomFields = sanitizeCustomFields(source.customFields)
const backendCustomFields = sanitizedCustomFields.map((field) => {
const value: Record<string, any> = {
type: field.type,
required: !!field.required,
}
if (field.options && field.options.length) {
value.options = field.options
}
if (field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '') {
value.defaultValue = field.defaultValue
}
const payload: Record<string, any> = {
key: field.name,
value,
}
if (field.id) {
payload.id = field.id
}
if (field.customFieldId) {
payload.customFieldId = field.customFieldId
}
return payload
}) as any
const backendPieces = sanitizePieces(source.pieces).map((piece) => {
const payload: Record<string, any> = {}
if ((piece as any).familyCode) {
payload.familyCode = (piece as any).familyCode
}
if (piece.typePieceId) {
payload.typePieceId = piece.typePieceId
}
if (piece.typePieceLabel) {
payload.typePieceLabel = piece.typePieceLabel
}
if (piece.reference) {
payload.reference = piece.reference
}
return payload
}) as any
const backendProducts = sanitizeProducts(source.products).map((product) => {
const payload: Record<string, any> = {}
if ((product as any).familyCode) {
payload.familyCode = (product as any).familyCode
}
if (product.typeProductId) {
payload.typeProductId = product.typeProductId
}
if (product.role) {
payload.role = product.role
}
return payload
}) as any
const mapSubcomponentForSave = (subcomponent: ComponentModelStructureNode): any => {
const payload: Record<string, any> = {}
if (subcomponent.typeComposantId) {
payload.typeComposantId = subcomponent.typeComposantId
}
if (subcomponent.modelId) {
payload.modelId = subcomponent.modelId
}
if (subcomponent.familyCode) {
payload.familyCode = subcomponent.familyCode
}
if (subcomponent.alias) {
payload.alias = subcomponent.alias
}
if (Array.isArray(subcomponent.subcomponents) && subcomponent.subcomponents.length) {
payload.subcomponents = subcomponent.subcomponents.map(mapSubcomponentForSave)
}
return payload
}
const backendSubcomponents = sanitizeSubcomponents(source.subcomponents).map(mapSubcomponentForSave) as any
const result: ComponentModelStructure = {
customFields: backendCustomFields,
pieces: backendPieces,
products: backendProducts,
subcomponents: backendSubcomponents,
}
if (typeof source.typeComposantId === 'string' && source.typeComposantId.length > 0) {
(result as any).typeComposantId = source.typeComposantId
}
if (typeof source.typeComposantLabel === 'string' && source.typeComposantLabel.length > 0) {
(result as any).typeComposantLabel = source.typeComposantLabel
}
if (typeof source.modelId === 'string' && source.modelId.length > 0) {
(result as any).modelId = source.modelId
}
if (typeof source.familyCode === 'string' && source.familyCode.length > 0) {
(result as any).familyCode = source.familyCode
}
if (typeof source.alias === 'string' && source.alias.length > 0) {
(result as any).alias = source.alias
}
return result
}
export const hydrateStructureForEditor = (input: any): ComponentModelStructure => {
const source = cloneStructure(input)
return {
customFields: hydrateCustomFields(source.customFields),
pieces: hydratePieces(source.pieces),
products: hydrateProducts(source.products),
subcomponents: hydrateSubcomponents(
Array.isArray(source.subcomponents) ? source.subcomponents : (source as any).subComponents,
),
typeComposantId: source.typeComposantId ?? '',
typeComposantLabel: source.typeComposantLabel ?? '',
modelId: source.modelId ?? '',
familyCode: source.familyCode ?? '',
alias: source.alias ?? '',
}
}
const mapComponentCustomFields = (fields: any[]) => {
if (!Array.isArray(fields)) {
return []
}
return hydrateCustomFields(fields).map((field, index) => {
const defaultValue =
field?.defaultValue !== undefined && field?.defaultValue !== null && field?.defaultValue !== ''
? field.defaultValue
: null
return {
name: typeof field?.name === 'string' ? field.name : '',
type: field?.type ?? 'text',
required: !!field?.required,
options: Array.isArray(field?.options) ? field.options : [],
optionsText: typeof field?.optionsText === 'string' ? field.optionsText : '',
defaultValue,
id: typeof (field as any)?.id === 'string' ? (field as any).id : undefined,
customFieldId:
typeof (field as any)?.customFieldId === 'string'
? (field as any).customFieldId
: undefined,
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
}
})
}
const mapComponentPieces = (pieces: any[]): ComponentModelPiece[] => {
if (!Array.isArray(pieces)) {
return []
}
return pieces.map((piece) => ({
reference: piece?.reference ?? '',
typePieceId: piece?.typePieceId ?? piece?.typePiece?.id ?? '',
typePieceLabel: piece?.typePieceLabel ?? piece?.typePiece?.name ?? '',
familyCode: piece?.familyCode ?? piece?.typePiece?.code ?? '',
role: piece?.role ?? '',
}))
}
const mapComponentProducts = (products: any[]): ComponentModelProduct[] => {
if (!Array.isArray(products)) {
return []
}
return products.map((product) => ({
reference: product?.reference ?? '',
typeProductId: product?.typeProductId ?? product?.typeProduct?.id ?? '',
typeProductLabel: product?.typeProductLabel ?? product?.typeProduct?.name ?? '',
familyCode: product?.familyCode ?? product?.typeProduct?.code ?? '',
role: product?.role ?? '',
}))
}
const mapSubcomponents = (components: any[]): ComponentModelStructureNode[] => {
if (!Array.isArray(components)) {
return []
}
return components.map((component) => ({
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposantLabel ?? component?.typeComposant?.name ?? '',
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
}))
}
export const extractStructureFromComponent = (component: any) => {
if (!component) {
return defaultStructure()
}
const raw = {
customFields: mapComponentCustomFields(component.customFields),
pieces: mapComponentPieces(component.pieces),
products: mapComponentProducts(component.products),
subcomponents: mapSubcomponents(
Array.isArray(component?.subcomponents)
? component.subcomponents
: component?.subComponents,
),
typeComposantId: component?.typeComposantId ?? component?.typeComposant?.id ?? '',
typeComposantLabel: component?.typeComposant?.name ?? component?.typeComposantLabel ?? '',
modelId: component?.modelId ?? '',
familyCode: component?.familyCode ?? component?.typeComposant?.code ?? '',
alias: component?.alias ?? component?.name ?? '',
}
return normalizeStructureForEditor(raw)
}
export const computeStructureStats = (structure: any): ModelStructurePreview => {
if (!structure || typeof structure !== 'object') {
return { customFields: 0, pieces: 0, products: 0, subcomponents: 0 }
}
return {
customFields: Array.isArray(structure.customFields) ? structure.customFields.length : 0,
pieces: Array.isArray(structure.pieces) ? structure.pieces.length : 0,
products: Array.isArray(structure.products) ? structure.products.length : 0,
subcomponents: Array.isArray(structure.subcomponents)
? structure.subcomponents.length
: Array.isArray(structure.subComponents)
? structure.subComponents.length
: 0,
}
}
export const formatStructurePreview = (structure: any) => {
const stats = computeStructureStats(structure)
if (!stats.customFields && !stats.pieces && !stats.products && !stats.subcomponents) {
return 'Structure vide'
}
const segments: string[] = []
if (stats.customFields) segments.push(`${stats.customFields} champ(s)`)
if (stats.pieces) segments.push(`${stats.pieces} pièce(s)`)
if (stats.products) segments.push(`${stats.products} produit(s)`)
if (stats.subcomponents) segments.push(`${stats.subcomponents} sous-composant(s)`)
return segments.join(' • ')
}

View File

@@ -0,0 +1,49 @@
import { uniqueConstructeurIds } from '../constructeurUtils'
export interface DefinitionOverridePayload {
name?: string
reference?: string
constructeurIds?: string[]
prix?: number
}
export const sanitizeDefinitionOverrides = (definition: any): DefinitionOverridePayload | null => {
if (!definition || typeof definition !== 'object') {
return null
}
const payload: DefinitionOverridePayload = {}
if (typeof definition.name === 'string') {
const name = definition.name.trim()
if (name.length > 0) {
payload.name = name
}
}
if (typeof definition.reference === 'string') {
const reference = definition.reference.trim()
if (reference.length > 0) {
payload.reference = reference
}
}
const constructeurIds = uniqueConstructeurIds(
definition.constructeurIds,
definition.constructeurId,
definition.constructeur,
definition.constructeurs,
)
if (constructeurIds.length) {
payload.constructeurIds = constructeurIds
}
if (definition.prix !== undefined && definition.prix !== null && definition.prix !== '') {
const parsed = Number(definition.prix)
if (!Number.isNaN(parsed)) {
payload.prix = parsed
}
}
return Object.keys(payload).length ? payload : null
}

View File

@@ -0,0 +1,177 @@
import {
createEmptyPieceModelStructure,
createEmptyProductModelStructure,
type PieceModelCustomField,
type PieceModelProduct,
type PieceModelStructure,
type PieceModelStructureEditorField,
type PieceModelStructureForEditor,
type ProductModelStructure,
} from '../types/inventory'
import { isPlainObject, sanitizeProducts, hydrateProducts } from './componentStructure'
export const defaultPieceStructure = (): PieceModelStructure => ({
...createEmptyPieceModelStructure(),
})
export const defaultProductStructure = (): ProductModelStructure => ({
...createEmptyProductModelStructure(),
})
const ensurePieceStructureShape = (input: any): PieceModelStructure => {
const base = createEmptyPieceModelStructure()
if (!isPlainObject(input)) {
return base
}
const clone: PieceModelStructure = {
...base,
customFields: Array.isArray((input as any).customFields) ? (input as any).customFields : [],
products: Array.isArray((input as any).products) ? (input as any).products : [],
}
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (key === 'customFields' || key === 'products') {
continue
}
clone[key] = value
}
return clone
}
export const clonePieceStructure = (input: any): PieceModelStructure => {
try {
const cloned = JSON.parse(JSON.stringify(input ?? defaultPieceStructure()))
return ensurePieceStructureShape(cloned)
} catch (_error) {
return defaultPieceStructure()
}
}
export const cloneProductStructure = (input: any): ProductModelStructure => {
return clonePieceStructure(input)
}
const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
if (!Array.isArray(fields)) {
return []
}
return fields
.map((field, index) => {
const name = typeof field?.name === 'string' ? field.name.trim() : ''
if (!name) {
return null
}
const type = typeof field?.type === 'string' && field.type ? field.type : 'text'
const required = !!field?.required
let options: string[] | undefined
if (type === 'select') {
const rawOptions = typeof field?.optionsText === 'string'
? field.optionsText
: Array.isArray(field?.options)
? field.options.join('\n')
: ''
const parsed = rawOptions
.split(/\r?\n/)
.map((option: string) => option.trim())
.filter((option: string) => option.length > 0)
options = parsed.length > 0 ? parsed : undefined
}
const result: PieceModelCustomField = { name, type, required }
if (options) {
result.options = options
}
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
result.orderIndex = orderIndex
return result
})
.filter((field): field is PieceModelCustomField => !!field)
}
const sanitizePieceProducts = (products: any[]): PieceModelProduct[] => {
return sanitizeProducts(products) as PieceModelProduct[]
}
export const normalizePieceStructureForSave = (input: any): PieceModelStructure => {
const source = clonePieceStructure(input)
const restEntries = Object.entries(source).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return {
...Object.fromEntries(restEntries),
products: sanitizePieceProducts(source.products || []),
customFields: sanitizePieceCustomFields(source.customFields),
}
}
const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField[] => {
if (!Array.isArray(fields)) {
return []
}
return fields.map((field, index) => ({
name: field?.name ?? '',
type: field?.type ?? 'text',
required: !!field?.required,
options: Array.isArray(field?.options) ? field.options : undefined,
optionsText: typeof field?.optionsText === 'string'
? field.optionsText
: Array.isArray(field?.options)
? field.options.join('\n')
: '',
orderIndex: typeof field?.orderIndex === 'number' ? field.orderIndex : index,
}))
}
export const hydratePieceStructureForEditor = (input: any): PieceModelStructureForEditor => {
const source = clonePieceStructure(input)
const payload: PieceModelStructureForEditor = {
...Object.fromEntries(
Object.entries(source).filter(([key]) => key !== 'customFields' && key !== 'products'),
),
products: hydrateProducts(source.products || []) as PieceModelProduct[],
customFields: hydratePieceCustomFields(source.customFields),
}
return payload
}
export const formatPieceStructurePreview = (structure: any) => {
if (!structure || typeof structure !== 'object') {
return 'Aucun champ personnalisé'
}
const customFields = Array.isArray((structure as any).customFields)
? (structure as any).customFields.length
: 0
const products = Array.isArray((structure as any).products)
? (structure as any).products.length
: 0
if (!customFields && !products) {
return 'Aucun produit ni champ personnalisé'
}
const segments: string[] = []
if (products) {
segments.push(`${products} produit(s)`)
}
if (customFields) {
segments.push(`${customFields} champ(s) personnalisé(s)`)
}
return segments.join(' · ')
}
export const normalizeProductStructureForSave = (input: any): ProductModelStructure =>
normalizePieceStructureForSave(input)
export const hydrateProductStructureForEditor = (input: any) =>
hydratePieceStructureForEditor(input)
export const formatProductStructurePreview = (structure: any) =>
formatPieceStructurePreview(structure)

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ export interface ComponentModelCustomField {
export interface ComponentModelPiece { export interface ComponentModelPiece {
typePieceId?: string typePieceId?: string
typePieceLabel?: string typePieceLabel?: string
typePiece?: { id?: string; name?: string; code?: string } | null
reference?: string reference?: string
familyCode?: string familyCode?: string
role?: string role?: string
@@ -25,6 +26,7 @@ export interface ComponentModelPiece {
export interface ComponentModelProduct { export interface ComponentModelProduct {
typeProductId?: string typeProductId?: string
typeProductLabel?: string typeProductLabel?: string
typeProduct?: { id?: string; name?: string; code?: string } | null
reference?: string reference?: string
familyCode?: string familyCode?: string
role?: string role?: string
@@ -33,6 +35,7 @@ export interface ComponentModelProduct {
export interface ComponentModelStructureNode { export interface ComponentModelStructureNode {
typeComposantId?: string typeComposantId?: string
typeComposantLabel?: string typeComposantLabel?: string
typeComposant?: { id?: string; name?: string }
modelId?: string modelId?: string
familyCode?: string familyCode?: string
alias?: string alias?: string
@@ -151,8 +154,8 @@ const validatePiece = (
const typePieceId = ensureString(value.typePieceId) const typePieceId = ensureString(value.typePieceId)
const typePieceLabel = ensureString(value.typePieceLabel) const typePieceLabel = ensureString(value.typePieceLabel)
const reference = ensureString(value.reference) const reference = ensureString(value.reference)
const familyCode = ensureString((value as any).familyCode) const familyCode = ensureString(value.familyCode)
const role = ensureString((value as any).role) const role = ensureString(value.role)
if (!typePieceId && !typePieceLabel && !reference && !familyCode) { if (!typePieceId && !typePieceLabel && !reference && !familyCode) {
issues.push(`${path}: au moins un identifiant, une famille ou une référence de pièce est requis`) issues.push(`${path}: au moins un identifiant, une famille ou une référence de pièce est requis`)
@@ -184,8 +187,8 @@ const validateStructureNode = (
const familyCode = ensureString(value.familyCode) const familyCode = ensureString(value.familyCode)
const alias = ensureString(value.alias) const alias = ensureString(value.alias)
const rawSubcomponents = Array.isArray((value as any).subcomponents) const rawSubcomponents = Array.isArray(value.subcomponents)
? (value as any).subcomponents ? value.subcomponents
: [] : []
const subcomponents: ComponentModelStructureNode[] = [] const subcomponents: ComponentModelStructureNode[] = []

View File

@@ -0,0 +1,16 @@
/**
* Shared API response helpers.
*
* Extracted from 10+ composables/components that each had an identical local
* copy of extractCollection (parsing hydra:member / member / data / array).
*/
export function extractCollection<T = any>(payload: unknown): T[] {
if (Array.isArray(payload)) return payload as T[]
const p = payload as Record<string, unknown> | null
if (Array.isArray(p?.member)) return p!.member as T[]
if (Array.isArray(p?.['hydra:member'])) return p!['hydra:member'] as T[]
if (Array.isArray(p?.items)) return p!.items as T[]
if (Array.isArray(p?.data)) return p!.data as T[]
return []
}

View File

@@ -6,7 +6,7 @@
*/ */
import { getFileIcon } from '~/utils/fileIcons' import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview' import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
export const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024 export const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024

View File

@@ -0,0 +1,349 @@
/**
* Pure functions for custom field resolution, merging, and deduplication.
*
* Extracted from ComponentItem.vue and PieceItem.vue which had ~350 LOC
* of identical custom field logic duplicated between them.
*/
// ---------------------------------------------------------------------------
// Field key / identity helpers
// ---------------------------------------------------------------------------
export function fieldKeyFromNameAndType(name: unknown, type: unknown): string | null {
const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''
const normalizedType = typeof type === 'string' ? type.trim().toLowerCase() : ''
return normalizedName ? `${normalizedName}::${normalizedType}` : null
}
export function resolveOrderIndex(field: any): number {
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
}
// ---------------------------------------------------------------------------
// Field accessors
// ---------------------------------------------------------------------------
export function resolveFieldKey(field: any, index: number): string {
return field?.id ?? field?.customFieldValueId ?? field?.customFieldId ?? field?.name ?? `field-${index}`
}
export function resolveFieldId(field: any): string | null {
return field?.customFieldValueId ?? null
}
export function resolveFieldName(field: any): string {
return field?.name ?? 'Champ'
}
export function resolveFieldType(field: any): string {
return field?.type ?? 'text'
}
export function resolveFieldOptions(field: any): string[] {
return field?.options ?? []
}
export function resolveFieldRequired(field: any): boolean {
return !!field?.required
}
export function resolveFieldReadOnly(field: any): boolean {
return !!field?.readOnly
}
export function resolveCustomFieldId(field: any): string | null {
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null
}
export function buildCustomFieldMetadata(field: any) {
return {
customFieldName: resolveFieldName(field),
customFieldType: resolveFieldType(field),
customFieldRequired: resolveFieldRequired(field),
customFieldOptions: resolveFieldOptions(field),
}
}
export function formatFieldDisplayValue(field: any): string {
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'
}
// ---------------------------------------------------------------------------
// Custom field ID resolution against candidate pool
// ---------------------------------------------------------------------------
export function ensureCustomFieldId(field: any, candidateFields: any[]): string | null {
const existingId = resolveCustomFieldId(field)
if (existingId) return existingId
const name = resolveFieldName(field)
if (!name || name === 'Champ') return null
const matches = candidateFields.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((c) => c?.id || c?.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
}
// ---------------------------------------------------------------------------
// Structure extraction
// ---------------------------------------------------------------------------
export function extractStructureCustomFields(structure: any): any[] {
if (!structure || typeof structure !== 'object') return []
const customFields = structure.customFields
return Array.isArray(customFields) ? customFields : []
}
// ---------------------------------------------------------------------------
// Deduplication & merge
// ---------------------------------------------------------------------------
export function deduplicateFieldDefinitions(definitions: any[]): any[] {
const result: any[] = []
const seen = new Set<string>()
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)
const key = id || nameKey
if (key && seen.has(key)) return
if (key) seen.add(key)
field.orderIndex = resolveOrderIndex(field)
result.push(field)
})
return result
}
export function mergeFieldDefinitionsWithValues(definitions: any[], values: any[]): any[] {
const definitionList = Array.isArray(definitions) ? definitions : []
const valueList = Array.isArray(values) ? values : []
const valueMap = new Map<string, any>()
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 = resolveCustomFieldId(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 = resolveCustomFieldId(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))
}
export function dedupeMergedFields(fields: any[]): any[] {
if (!Array.isArray(fields) || fields.length <= 1) {
return Array.isArray(fields)
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
: []
}
const seen = new Map<string, any>()
const result: any[] = []
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 = field.type || resolveFieldType(field)
const fieldId = resolveCustomFieldId(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))
}
// ---------------------------------------------------------------------------
// Definition sources builder
// ---------------------------------------------------------------------------
export function buildDefinitionSources(entity: any, entityType: 'composant' | 'piece'): any[] {
const definitions: any[] = []
const pushFields = (collection: any) => {
if (Array.isArray(collection)) definitions.push(...collection)
}
if (entityType === 'composant') {
const requirement = entity.typeMachineComponentRequirement || {}
const type = requirement.typeComposant || entity.typeComposant || {}
pushFields(entity.customFields)
pushFields(entity.definition?.customFields)
pushFields(type.customFields)
pushFields(requirement.customFields)
pushFields(requirement.definition?.customFields)
;[
entity.definition?.structure,
type.structure,
type.componentSkeleton,
requirement.structure,
requirement.componentSkeleton,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure)
if (fields.length) definitions.push(...fields)
})
} else {
const requirement = entity.typeMachinePieceRequirement || {}
const type = requirement.typePiece || entity.typePiece || {}
pushFields(entity.customFields)
pushFields(entity.definition?.customFields)
pushFields(entity.typePiece?.customFields)
pushFields(type.customFields)
pushFields(requirement.typePiece?.customFields)
pushFields(requirement.customFields)
pushFields(requirement.definition?.customFields)
;[
entity.definition?.structure,
entity.typePiece?.structure,
type.structure,
type.pieceSkeleton,
entity.typePiece?.pieceSkeleton,
requirement.structure,
requirement.pieceSkeleton,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure)
if (fields.length) definitions.push(...fields)
})
}
return deduplicateFieldDefinitions(definitions)
}
// ---------------------------------------------------------------------------
// Candidate fields builder
// ---------------------------------------------------------------------------
export function buildCandidateCustomFields(entity: any, definitionSources: any[]): any[] {
const map = new Map<string, any>()
const register = (collection: any[]) => {
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((entity.customFieldValues || []).map((value: any) => value?.customField))
register(definitionSources)
return Array.from(map.values())
}

View File

@@ -9,6 +9,7 @@ const globals = {
}; };
const relaxedRules = { const relaxedRules = {
// Vue rules — relaxed for legacy code
'vue/no-parsing-error': 'off', 'vue/no-parsing-error': 'off',
'vue/no-required-prop-with-default': 'off', 'vue/no-required-prop-with-default': 'off',
'vue/no-mutating-props': 'off', 'vue/no-mutating-props': 'off',
@@ -21,8 +22,25 @@ const relaxedRules = {
'vue/no-multiple-template-root': 'off', 'vue/no-multiple-template-root': 'off',
'vue/v-on-event-hyphenation': 'off', 'vue/v-on-event-hyphenation': 'off',
'vue/require-default-prop': 'off', 'vue/require-default-prop': 'off',
'no-console': 'off',
// Console — allow console.error only
'no-console': ['warn', { allow: ['error'] }],
// Unused vars — warn, ignore underscore-prefixed
'no-unused-vars': 'off', 'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
// TypeScript — progressive strictness
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-dynamic-delete': 'off',
'@typescript-eslint/no-invalid-void-type': 'off',
'@typescript-eslint/unified-signatures': 'off',
// Formatting — handled by Prettier/stylistic
'require-await': 'off', 'require-await': 'off',
'comma-dangle': 'off', 'comma-dangle': 'off',
curly: 'off', curly: 'off',
@@ -30,6 +48,7 @@ const relaxedRules = {
'space-before-function-paren': 'off', 'space-before-function-paren': 'off',
'arrow-parens': 'off', 'arrow-parens': 'off',
semi: 'off', semi: 'off',
'@typescript-eslint/semi': 'off',
quotes: 'off', quotes: 'off',
'func-call-spacing': 'off', 'func-call-spacing': 'off',
'no-trailing-spaces': 'off', 'no-trailing-spaces': 'off',
@@ -39,12 +58,6 @@ const relaxedRules = {
'no-irregular-whitespace': 'off', 'no-irregular-whitespace': 'off',
'no-useless-escape': 'off', 'no-useless-escape': 'off',
'nuxt/prefer-import-meta': 'off', 'nuxt/prefer-import-meta': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/semi': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-dynamic-delete': 'off',
'@typescript-eslint/no-invalid-void-type': 'off',
'@typescript-eslint/unified-signatures': 'off',
}; };
export default await nuxt( export default await nuxt(

571
package-lock.json generated
View File

@@ -22,10 +22,14 @@
"@types/node": "^25.2.1", "@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1", "@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0", "eslint-plugin-vue": "^10.5.0",
"happy-dom": "^20.5.3",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"unplugin-icons": "^0.19.3", "unplugin-icons": "^0.19.3",
"vitest": "^4.0.18",
"vue-eslint-parser": "^10.2.0" "vue-eslint-parser": "^10.2.0"
} }
}, },
@@ -2119,6 +2123,13 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@one-ini/wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
"dev": true,
"license": "MIT"
},
"node_modules/@oxc-minify/binding-android-arm64": { "node_modules/@oxc-minify/binding-android-arm64": {
"version": "0.87.0", "version": "0.87.0",
"resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm64/-/binding-android-arm64-0.87.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm64/-/binding-android-arm64-0.87.0.tgz",
@@ -3245,9 +3256,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.29", "version": "1.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
"integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==", "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/plugin-alias": { "node_modules/@rollup/plugin-alias": {
@@ -3747,6 +3758,13 @@
"integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==", "integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==",
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@stylistic/eslint-plugin": { "node_modules/@stylistic/eslint-plugin": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.4.0.tgz", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.4.0.tgz",
@@ -4040,6 +4058,24 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4075,6 +4111,23 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.44.1", "version": "8.44.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
@@ -4631,18 +4684,18 @@
} }
}, },
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.1", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
"integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==", "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rolldown/pluginutils": "1.0.0-beta.29" "@rolldown/pluginutils": "1.0.0-rc.2"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"peerDependencies": { "peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
@@ -4666,11 +4719,126 @@
"vue": "^3.0.0" "vue": "^3.0.0"
} }
}, },
"node_modules/@vitejs/plugin-vue-jsx/node_modules/@rolldown/pluginutils": { "node_modules/@vitest/expect": {
"version": "1.0.0-beta.40", "version": "4.0.18",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.40.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==", "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"license": "MIT" "dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
}, },
"node_modules/@volar/language-core": { "node_modules/@volar/language-core": {
"version": "2.4.23", "version": "2.4.23",
@@ -4951,6 +5119,17 @@
"integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==", "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/test-utils": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz",
"integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-beautify": "^1.14.9",
"vue-component-type-helpers": "^2.0.0"
}
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@@ -5207,6 +5386,16 @@
"devOptional": true, "devOptional": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-kit": { "node_modules/ast-kit": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.2.tgz",
@@ -5660,6 +5849,16 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5958,6 +6157,24 @@
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
}
},
"node_modules/config-chain/node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true,
"license": "ISC"
},
"node_modules/consola": { "node_modules/consola": {
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
@@ -6609,6 +6826,51 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/editorconfig": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "9.0.1",
"semver": "^7.5.3"
},
"bin": {
"editorconfig": "bin/editorconfig"
},
"engines": {
"node": ">=14"
}
},
"node_modules/editorconfig/node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/editorconfig/node_modules/minimatch": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -7301,6 +7563,16 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1" "url": "https://github.com/sindresorhus/execa?sponsor=1"
} }
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exsolve": { "node_modules/exsolve": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
@@ -7829,6 +8101,37 @@
"uncrypto": "^0.1.3" "uncrypto": "^0.1.3"
} }
}, },
"node_modules/happy-dom": {
"version": "20.5.3",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.5.3.tgz",
"integrity": "sha512-xqAxGnkRU0KNhheHpxb3uScqg/aehqUiVto/a9ApWMyNvnH9CAqHYq9dEPAovM6bOGbLstmTfGIln5ZIezEU0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"entities": "^6.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.18.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/happy-dom/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -8442,6 +8745,64 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-beautify": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
"integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"config-chain": "^1.1.13",
"editorconfig": "^1.0.4",
"glob": "^10.4.2",
"js-cookie": "^3.0.5",
"nopt": "^7.2.1"
},
"bin": {
"css-beautify": "js/bin/css-beautify.js",
"html-beautify": "js/bin/html-beautify.js",
"js-beautify": "js/bin/js-beautify.js"
},
"engines": {
"node": ">=14"
}
},
"node_modules/js-beautify/node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
"integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/js-beautify/node_modules/nopt": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
"integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
"dev": true,
"license": "ISC",
"dependencies": {
"abbrev": "^2.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -9208,9 +9569,9 @@
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.19", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
@@ -9978,6 +10339,17 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/ofetch": { "node_modules/ofetch": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.4.1.tgz",
@@ -11256,6 +11628,13 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"dev": true,
"license": "ISC"
},
"node_modules/protocols": { "node_modules/protocols": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz", "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz",
@@ -11943,6 +12322,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -12089,6 +12475,13 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/standard-as-callback": { "node_modules/standard-as-callback": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@@ -12105,9 +12498,9 @@
} }
}, },
"node_modules/std-env": { "node_modules/std-env": {
"version": "3.9.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/streamx": { "node_modules/streamx": {
@@ -12536,12 +12929,22 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinyexec": { "node_modules/tinybench": {
"version": "1.0.1", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -12558,6 +12961,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -13617,6 +14030,84 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vscode-uri": { "node_modules/vscode-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@@ -13653,6 +14144,13 @@
"ufo": "^1.6.1" "ufo": "^1.6.1"
} }
}, },
"node_modules/vue-component-type-helpers": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz",
"integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==",
"dev": true,
"license": "MIT"
},
"node_modules/vue-devtools-stub": { "node_modules/vue-devtools-stub": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz", "resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
@@ -13710,6 +14208,16 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -13735,6 +14243,23 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View File

@@ -10,7 +10,9 @@
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"start": "nuxt start", "start": "nuxt start",
"lint": "eslint . --ext .js,.ts,.vue", "lint": "eslint . --ext .js,.ts,.vue",
"lint:fix": "npm run lint -- --fix" "lint:fix": "npm run lint -- --fix",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -28,10 +30,14 @@
"@types/node": "^25.2.1", "@types/node": "^25.2.1",
"@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1", "@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0", "eslint-plugin-vue": "^10.5.0",
"happy-dom": "^20.5.3",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"unplugin-icons": "^0.19.3", "unplugin-icons": "^0.19.3",
"vitest": "^4.0.18",
"vue-eslint-parser": "^10.2.0" "vue-eslint-parser": "^10.2.0"
} }
} }

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